Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
b596516
feat: add oneshot command and per-worktree on-merge action
hugocasa May 8, 2026
8e1b21a
feat: linear handoff — post conversation + resume from issue
hugocasa May 11, 2026
035ab39
refactor: rename --resume-from-linear to --from-linear, always load i…
hugocasa May 11, 2026
5044315
feat: restrict --post-to-linear to team keys
hugocasa May 11, 2026
d89aff2
refactor: oneshot exposes one --linear flag with auto-detect
hugocasa May 11, 2026
fd65540
fix: stream Claude conversations in oneshot via history polling
hugocasa May 12, 2026
35af916
fix: silence Claude session JSONL parse-warning spam
hugocasa May 12, 2026
d44439e
feat: tag oneshot worktrees with a source badge in the UI
hugocasa May 12, 2026
af8c300
feat: harden oneshot — autonomous prompt, tool-call visibility, idle …
hugocasa May 12, 2026
8d7b36f
feat: pre-create linear issue for oneshot team-key target
hugocasa May 12, 2026
24e67b2
feat: oneshot auto-closes worktree session on exit unless --keep-open
hugocasa May 12, 2026
eeaaa29
revert: drop per-worktree on-merge action — keep only the global toggle
hugocasa May 12, 2026
fc19169
refactor: route LinearPanel click through the fromLinear seed
hugocasa May 12, 2026
e8a9a55
Merge remote-tracking branch 'origin/main' into feat/oneshot-and-on-m…
hugocasa May 12, 2026
c6976e6
fix: drop stale onMergeAction refs from test fixtures + dedupe import
hugocasa May 12, 2026
0a0607c
refactor: address PR review — env-source unification, DRY, cleanups
hugocasa May 12, 2026
32dd4e1
refactor: address PR review #2 — preflight, drop dead Linear endpoints
hugocasa May 12, 2026
7380638
fix: stale flag-name strings + WebSocket reconnect ceiling
hugocasa May 12, 2026
75331ea
feat: post-to-linear skips dialog when worktree is linked to an issue
hugocasa May 13, 2026
4367a50
refactor: address PR review #5 — reconnect tuning, Zod validation, DRY
hugocasa May 13, 2026
6fae4ee
refactor: address PR review #6 — Zod-derived type, confirm guard, par…
hugocasa May 13, 2026
d75c216
refactor: address PR review #7 — Post-to-Linear in-flight guard, pars…
hugocasa May 13, 2026
05ecd19
Merge remote-tracking branch 'origin/main' into feat/oneshot-and-on-m…
hugocasa May 13, 2026
3fecfac
test: cover Post-to-Linear confirm flow and in-flight guard in App.te…
hugocasa May 13, 2026
42b1c00
fix: send empty body when reopening a worktree from the UI
hugocasa May 13, 2026
bea54a6
feat: linear auto-handler — _oneshot label triggers a oneshot run
hugocasa May 13, 2026
30143ac
refactor: move oneshot auto-close + Linear post-back to server
hugocasa May 13, 2026
9f94d13
Merge remote-tracking branch 'origin/main' into feat/oneshot-and-on-m…
hugocasa May 13, 2026
bc07c3f
refactor: address self-review before reopening PR
hugocasa May 13, 2026
17c9c4e
refactor: dedupe linear auto-create filters + seed-from-Linear deps
hugocasa May 13, 2026
1b7ba37
refactor: address PR review #8 — disarm on terminal input, race-safe …
hugocasa May 13, 2026
4515cb5
refactor: address PR #238 review — label dedup eviction, runtime-stat…
hugocasa May 13, 2026
efed8b8
fix: cold-start race + runtime mirror in oneshot watcher
hugocasa May 13, 2026
9c4840e
chore: rename Linear oneshot trigger label `_oneshot` → `webmux_oneshot`
hugocasa May 13, 2026
4dfdda0
fix: don't disarm oneshot watcher when reopening a closed session
hugocasa May 13, 2026
9523e81
fix: --resume requires --prompt + dedupe permanent linear-auto-create…
hugocasa May 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ Run agents in isolated Docker containers for untrusted or experimental work. web

See your assigned Linear issues alongside your worktrees. Webmux matches branches to issues automatically, so you can browse your backlog, pick an issue, and spin up a worktree for it in one click.

**Auto-create from labels.** When the server polls your assigned Linear issues, it acts on two labels:

- **`webmux`** — auto-creates a worktree for the issue, seeded with the issue title + description as the agent prompt.
- **`webmux_oneshot`** — launches the issue via `webmux oneshot` (autonomous run; auto-closes the session and posts the conversation back to the Linear issue when the agent terminates or opens a PR). When both labels are present, `webmux_oneshot` wins.

Each issue is processed once while it stays in Todo + labeled. Remove the label and re-add it to retrigger.

## Quick Start

```bash
Expand Down
1 change: 1 addition & 0 deletions backend/src/__tests__/agent-chat-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const TEST_CONFIG: ProjectConfig = {
},
lifecycleHooks: {},
autoName: null,
oneshot: { systemPrompt: "" },
};

describe("resolveAgentChatSupport", () => {
Expand Down
1 change: 1 addition & 0 deletions backend/src/__tests__/agent-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const TEST_CONFIG: ProjectConfig = {
},
lifecycleHooks: {},
autoName: null,
oneshot: { systemPrompt: "" },
};

describe("agent-registry", () => {
Expand Down
42 changes: 36 additions & 6 deletions backend/src/__tests__/agent-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ describe("agent-service command builders", () => {
expect(claude).not.toContain("--continue");
});

it("uses claude continue on resume without replaying the initial prompt", () => {
it("uses claude continue on resume and skips system prompt; no prompt = no replay", () => {
const command = buildAgentPaneCommand({
agent: builtInAgent("claude"),
runtimeEnvPath: "/tmp/gitdir/webmux/runtime.env",
Expand All @@ -93,14 +93,29 @@ describe("agent-service command builders", () => {
profileName: "default",
yolo: true,
systemPrompt: "stay focused",
prompt: "fix the tests",
launchMode: "resume",
});

expect(command).toContain("claude --dangerously-skip-permissions --continue");
expect(command).not.toContain("--append-system-prompt");
expect(command).not.toContain("fix the tests");
expect(command).not.toContain("stay focused");
expect(command).not.toContain(" -- ");
});

it("appends the follow-up prompt to claude --continue when one is provided", () => {
const command = buildAgentPaneCommand({
agent: builtInAgent("claude"),
runtimeEnvPath: "/tmp/gitdir/webmux/runtime.env",
repoRoot: "/repo",
worktreePath: "/repo/__worktrees/feature",
branch: "feature",
profileName: "default",
yolo: true,
prompt: "fix the tests",
launchMode: "resume",
});

expect(command).toContain("claude --dangerously-skip-permissions --continue -- 'fix the tests'");
});

it("builds docker commands that exec inside the container", () => {
Expand Down Expand Up @@ -154,7 +169,7 @@ describe("agent-service command builders", () => {
expect(shell).toContain("elif [ -x /bin/sh ]; then exec /bin/sh -i;");
});

it("uses codex resume --last on resume without replaying the initial prompt", () => {
it("uses codex resume --last and skips developer_instructions; no prompt = no replay", () => {
const command = buildAgentPaneCommand({
agent: builtInAgent("codex"),
runtimeEnvPath: "/tmp/gitdir/webmux/runtime.env",
Expand All @@ -164,14 +179,29 @@ describe("agent-service command builders", () => {
profileName: "default",
yolo: true,
systemPrompt: "stay focused",
prompt: "ship the fix",
launchMode: "resume",
});

expect(command).toContain("codex --enable codex_hooks --yolo resume --last");
expect(command).not.toContain("developer_instructions=");
expect(command).not.toContain("ship the fix");
expect(command).not.toContain("stay focused");
expect(command).not.toContain(" -- ");
});

it("appends the follow-up prompt to codex resume --last when one is provided", () => {
const command = buildAgentPaneCommand({
agent: builtInAgent("codex"),
runtimeEnvPath: "/tmp/gitdir/webmux/runtime.env",
repoRoot: "/repo",
worktreePath: "/repo/__worktrees/feature",
branch: "feature",
profileName: "default",
yolo: true,
prompt: "ship the fix",
launchMode: "resume",
});

expect(command).toContain("codex --enable codex_hooks --yolo resume --last -- 'ship the fix'");
});

it("uses -- before the prompt so dash-prefixed prompts are not parsed as flags", () => {
Expand Down
4 changes: 4 additions & 0 deletions backend/src/__tests__/agents-ui-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ describe("buildAgentsUiWorktreeSummary", () => {
creation: {
phase: "starting_session",
},
source: "ui",
oneshot: null,
}, {
provider: "codexAppServer",
conversationId: "thr_123",
Expand Down Expand Up @@ -105,6 +107,8 @@ describe("buildAgentsUiWorktreeSummary", () => {
prs: [],
linearIssue: null,
creation: null,
source: "ui",
oneshot: null,
}, null);

expect(summary.conversation).toBeNull();
Expand Down
106 changes: 105 additions & 1 deletion backend/src/__tests__/claude-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,17 +57,121 @@ describe("claude-cli adapter", () => {
id: "user-1",
turnId: "user-1",
role: "user",
kind: "text",
text: "Inspect the failing tests",
createdAt: "2026-04-14T15:00:00.000Z",
},
{
id: "assistant-1",
id: "assistant-thinking:1",
turnId: "user-1",
role: "assistant",
kind: "text",
text: "Let me inspect that.",
createdAt: "2026-04-14T15:00:01.000Z",
},
{
id: "assistant-1:2",
turnId: "user-1",
role: "assistant",
kind: "text",
text: "The failure comes from the stale snapshot.",
createdAt: "2026-04-14T15:00:05.000Z",
},
],
});
});

it("surfaces tool_use and tool_result blocks as intermediate messages", () => {
const session = buildClaudeSessionFromText({
path: "/tmp/session.jsonl",
sessionId: "session-2",
text: [
JSON.stringify({
type: "user",
uuid: "user-1",
timestamp: "2026-04-14T15:00:00.000Z",
cwd: "/tmp",
message: { role: "user", content: "Read foo.txt" },
}),
JSON.stringify({
type: "assistant",
uuid: "assistant-1",
timestamp: "2026-04-14T15:00:01.000Z",
message: {
role: "assistant",
stop_reason: "tool_use",
content: [
{ type: "text", text: "Reading the file." },
{ type: "tool_use", id: "tool-1", name: "Read", input: { file_path: "/tmp/foo.txt" } },
],
},
}),
JSON.stringify({
type: "user",
uuid: "tool-result-1",
timestamp: "2026-04-14T15:00:02.000Z",
message: {
role: "user",
content: [
{ type: "tool_result", tool_use_id: "tool-1", content: "hello world" },
],
},
}),
JSON.stringify({
type: "assistant",
uuid: "assistant-2",
timestamp: "2026-04-14T15:00:03.000Z",
message: {
role: "assistant",
stop_reason: "end_turn",
content: [{ type: "text", text: "It says hello world." }],
},
}),
].join("\n"),
});

expect(session.messages).toEqual([
{
id: "user-1",
turnId: "user-1",
role: "user",
kind: "text",
text: "Read foo.txt",
createdAt: "2026-04-14T15:00:00.000Z",
},
{
id: "assistant-1:1",
turnId: "user-1",
role: "assistant",
kind: "text",
text: "Reading the file.",
createdAt: "2026-04-14T15:00:01.000Z",
},
{
id: "assistant-1:2",
turnId: "user-1",
role: "assistant",
kind: "toolUse",
toolName: "Read",
text: `{"file_path":"/tmp/foo.txt"}`,
createdAt: "2026-04-14T15:00:01.000Z",
},
{
id: "tool-result-1:3",
turnId: "user-1",
role: "user",
kind: "toolResult",
text: "hello world",
createdAt: "2026-04-14T15:00:02.000Z",
},
{
id: "assistant-2:4",
turnId: "user-1",
role: "assistant",
kind: "text",
text: "It says hello world.",
createdAt: "2026-04-14T15:00:03.000Z",
},
]);
});
});
2 changes: 2 additions & 0 deletions backend/src/__tests__/claude-conversation-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ function makeWorktree(): WorktreeSnapshot {
prs: [],
linearIssue: null,
creation: null,
source: "ui",
oneshot: null,
};
}

Expand Down
Loading
Loading