diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index a3e192234..8e3133afb 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -232,6 +232,13 @@ ade lanes list --text ade lanes create "fix-checkout-flow" --parent main ade lanes create "lin-123" --linear-issue-json '{"id":"...","identifier":"LIN-123","title":"...","projectId":"...","projectSlug":"...","teamId":"...","teamKey":"...","stateId":"...","stateName":"Todo","stateType":"unstarted","priority":2,"priorityLabel":"high","labels":[],"assigneeId":null,"assigneeName":null,"createdAt":"...","updatedAt":"..."}' ade lanes reparent lane-child --parent lane-parent --stack-base-branch main +ade lanes create-from-linear --issue-id ENG-431 --start-chat --provider codex --model +ade lanes batch-create-from-linear --linear-issues-json '[{"id":"...","identifier":"ENG-431"},{"id":"...","identifier":"ENG-440"}]' +ade chat attach-linear-issue --issue-id ENG-431 +ade chat create --from-linear-issue ENG-431 +ade linear attach --this-session --issue-id ENG-431 # attach to the current CLI session ($ADE_CHAT_SESSION_ID) +ade linear comment "Pushed a fix; CI running" # write back to the session's attached issue over the daemon bridge +ade linear set-state ENG-431 ade --role cto linear quick-view --text ade --role cto linear search-issues --query "auth" --state-type started,unstarted --first 50 ade --role cto linear issue-comments --issue-id diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index ae755275e..87afeb303 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -145,6 +145,21 @@ function createRuntime() { name: name ?? "Imported lane", branchRef, })), + linkLinearIssues: vi.fn((args: { laneId: string; issues: unknown[] }) => + args.issues.map((issue, index) => ({ id: `link-${index}`, laneId: args.laneId, issue })), + ), + unlinkLinearIssues: vi.fn(() => true), + attachLinearIssueToSession: vi.fn((args: { chatSessionId: string; issues: unknown[] }) => + args.issues.map((issue, index) => ({ + id: `session-link-${index}`, + chatSessionId: args.chatSessionId, + issue, + })), + ), + detachLinearIssueFromSession: vi.fn(() => true), + listLinearIssuesForSession: vi.fn((args: { chatSessionId: string }) => [ + { id: "session-link-0", chatSessionId: args.chatSessionId, issue: { id: "issue-1", identifier: "ENG-1" } }, + ]), delete: vi.fn(async () => {}) }, sessionService: { @@ -2868,6 +2883,74 @@ describe("adeRpcServer", () => { }); + it("routes Linear attach/detach/list and the issue write-bridge through run_ade_action", async () => { + const fixture = createRuntime(); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + await initialize(handler, { callerId: "agent-1", role: "agent" }); + + // Session-scoped attach: issues array keyed by chatSessionId. + const attach = await callTool(handler, "run_ade_action", { + domain: "lane", + action: "attachLinearIssueToSession", + args: { chatSessionId: "session-9", issues: [{ id: "issue-1", identifier: "ENG-431" }] }, + }); + expect(attach?.isError).toBeUndefined(); + expect(fixture.runtime.laneService.attachLinearIssueToSession).toHaveBeenCalledWith({ + chatSessionId: "session-9", + issues: [{ id: "issue-1", identifier: "ENG-431" }], + }); + + // Detach: chatSessionId + optional issueId. + const detach = await callTool(handler, "run_ade_action", { + domain: "lane", + action: "detachLinearIssueFromSession", + args: { chatSessionId: "session-9", issueId: "ENG-431" }, + }); + expect(detach?.isError).toBeUndefined(); + expect(fixture.runtime.laneService.detachLinearIssueFromSession).toHaveBeenCalledWith({ + chatSessionId: "session-9", + issueId: "ENG-431", + }); + + // List: object arg. + const list = await callTool(handler, "run_ade_action", { + domain: "lane", + action: "listLinearIssuesForSession", + args: { chatSessionId: "session-9" }, + }); + expect(list?.isError).toBeUndefined(); + expect(fixture.runtime.laneService.listLinearIssuesForSession).toHaveBeenCalledWith({ + chatSessionId: "session-9", + }); + expect(list.structuredContent.result).toHaveLength(1); + + // Lane-scoped unlink (issueId omitted = remove all non-primary links). + const unlink = await callTool(handler, "run_ade_action", { + domain: "lane", + action: "unlinkLinearIssues", + args: { laneId: "lane-1" }, + }); + expect(unlink?.isError).toBeUndefined(); + expect(fixture.runtime.laneService.unlinkLinearIssues).toHaveBeenCalledWith({ laneId: "lane-1" }); + + // Write-bridge: createComment + updateIssueState (positional). + const comment = await callTool(handler, "run_ade_action", { + domain: "linear_issue_tracker", + action: "createComment", + argsList: ["ENG-431", "All green"], + }); + expect(comment?.isError).toBeUndefined(); + expect(fixture.runtime.linearIssueTracker.createComment).toHaveBeenCalledWith("ENG-431", "All green"); + + const setState = await callTool(handler, "run_ade_action", { + domain: "linear_issue_tracker", + action: "updateIssueState", + argsList: ["ENG-431", "state-done"], + }); + expect(setState?.isError).toBeUndefined(); + expect(fixture.runtime.linearIssueTracker.updateIssueState).toHaveBeenCalledWith("ENG-431", "state-done"); + }); + it("invokes review.startRun through ADE actions without dropping unlimited budgets", async () => { const fixture = createRuntime(); const startArgs = { diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 9966e95b1..077a1736c 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -2065,6 +2065,397 @@ describe("ADE CLI", () => { }); }); + it("accepts attach-linear-issue as a lane-scoped link alias with --issue-id shorthand", () => { + const plan = buildCliPlan([ + "lanes", + "attach-linear-issue", + "lane-7", + "--issue-id", + "ENG-431", + "--close-on-merge", + ]); + + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + expect(plan.steps[0]?.params).toEqual({ + name: "run_ade_action", + arguments: { + domain: "lane", + action: "linkLinearIssues", + args: { + laneId: "lane-7", + issues: [{ id: "ENG-431", identifier: "ENG-431" }], + closeOnMerge: true, + }, + }, + }); + }); + + it("maps lane detach-linear-issue to lane.unlinkLinearIssues (all non-primary by default)", () => { + const detachAll = buildCliPlan(["lanes", "detach-linear-issue", "lane-7"]); + expect(detachAll.kind).toBe("execute"); + if (detachAll.kind !== "execute") return; + expect(detachAll.steps[0]?.params).toEqual({ + name: "run_ade_action", + arguments: { + domain: "lane", + action: "unlinkLinearIssues", + args: { laneId: "lane-7" }, + }, + }); + + const detachOne = buildCliPlan([ + "lanes", + "detach-linear-issue", + "lane-7", + "--issue-id", + "ENG-431", + ]); + expect(detachOne.kind).toBe("execute"); + if (detachOne.kind !== "execute") return; + expect(detachOne.steps[0]?.params).toEqual({ + name: "run_ade_action", + arguments: { + domain: "lane", + action: "unlinkLinearIssues", + args: { laneId: "lane-7", issueId: "ENG-431" }, + }, + }); + }); + + it("maps chat attach/detach/list to session-scoped lane actions", () => { + // attachLinearIssueToSession takes an issues array keyed by chatSessionId. + const attach = buildCliPlan([ + "chat", + "attach-linear-issue", + "session-9", + "--issue-id", + "ENG-431", + ]); + expect(attach.kind).toBe("execute"); + if (attach.kind !== "execute") return; + expect(attach.steps[0]?.params).toEqual({ + name: "run_ade_action", + arguments: { + domain: "lane", + action: "attachLinearIssueToSession", + args: { + chatSessionId: "session-9", + issues: [{ id: "ENG-431", identifier: "ENG-431" }], + }, + }, + }); + + // detach with a specific issueId. + const detach = buildCliPlan([ + "chat", + "detach-linear-issue", + "session-9", + "--issue-id", + "ENG-431", + ]); + expect(detach.kind).toBe("execute"); + if (detach.kind !== "execute") return; + expect(detach.steps[0]?.params).toEqual({ + name: "run_ade_action", + arguments: { + domain: "lane", + action: "detachLinearIssueFromSession", + args: { chatSessionId: "session-9", issueId: "ENG-431" }, + }, + }); + + // detach with no issueId detaches every issue from the session. + const detachAll = buildCliPlan(["chat", "detach-linear-issue", "session-9"]); + expect(detachAll.kind).toBe("execute"); + if (detachAll.kind !== "execute") return; + expect(detachAll.steps[0]?.params).toEqual({ + name: "run_ade_action", + arguments: { + domain: "lane", + action: "detachLinearIssueFromSession", + args: { chatSessionId: "session-9" }, + }, + }); + + // listLinearIssuesForSession takes an object arg. + const list = buildCliPlan(["chat", "linear-issues", "session-9"]); + expect(list.kind).toBe("execute"); + if (list.kind !== "execute") return; + expect(list.steps[0]?.params).toEqual({ + name: "run_ade_action", + arguments: { + domain: "lane", + action: "listLinearIssuesForSession", + args: { chatSessionId: "session-9" }, + }, + }); + }); + + it("attaches multiple issues to a session in one call", () => { + const plan = buildCliPlan([ + "chat", + "attach-linear-issue", + "session-9", + "--linear-issue-json", + '[{"id":"a","identifier":"ENG-1"},{"id":"b","identifier":"ENG-2"}]', + ]); + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + expect(plan.steps[0]?.params).toMatchObject({ + arguments: { + args: { + chatSessionId: "session-9", + issues: [ + { id: "a", identifier: "ENG-1" }, + { id: "b", identifier: "ENG-2" }, + ], + }, + }, + }); + }); + + it("resolves the session id from ADE_CHAT_SESSION_ID for chat attach", () => { + const prev = process.env.ADE_CHAT_SESSION_ID; + process.env.ADE_CHAT_SESSION_ID = "env-session"; + try { + const plan = buildCliPlan(["chat", "attach-linear-issue", "--issue-id", "ENG-1"]); + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + expect(plan.steps[0]?.params).toMatchObject({ + arguments: { args: { chatSessionId: "env-session" } }, + }); + } finally { + if (prev === undefined) delete process.env.ADE_CHAT_SESSION_ID; + else process.env.ADE_CHAT_SESSION_ID = prev; + } + }); + + it("builds lanes create-from-linear as a single create_lane step without --start-chat", () => { + const plan = buildCliPlan([ + "lanes", + "create-from-linear", + "--linear-issue-json", + '{"id":"issue-1","identifier":"ENG-431","title":"Fix OAuth"}', + "--base", + "main", + ]); + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + expect(plan.steps).toHaveLength(1); + expect(plan.steps[0]?.params).toEqual({ + name: "create_lane", + arguments: { + name: "Fix OAuth", + linearIssue: { id: "issue-1", identifier: "ENG-431", title: "Fix OAuth" }, + baseBranch: "main", + }, + }); + }); + + it("chains create_lane -> chat createSession -> kickoff for create-from-linear --start-chat", () => { + const plan = buildCliPlan([ + "lanes", + "create-from-linear", + "--linear-issue-json", + '{"id":"issue-1","identifier":"ENG-431","title":"Fix OAuth","url":"https://linear.app/x/ENG-431"}', + "--start-chat", + "--provider", + "codex", + "--model", + "gpt-5", + ]); + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + expect(plan.steps).toHaveLength(3); + expect(plan.steps[0]?.key).toBe("lane"); + + // Step 2 derives laneId from the create_lane result. + const chatStep = plan.steps[1]!; + expect(typeof chatStep.params).toBe("function"); + const chatParams = (chatStep.params as (v: Record) => Record)({ + lane: { domain: "lane", action: "create", result: { lane: { id: "lane-new" } } }, + }); + expect(chatParams).toMatchObject({ + name: "run_ade_action", + arguments: { + domain: "chat", + action: "createSession", + args: { laneId: "lane-new", surface: "work", provider: "codex", model: "gpt-5", modelId: "gpt-5" }, + }, + }); + + // Step 3 derives sessionId from the createSession result and sends a kickoff. + const sendStep = plan.steps[2]!; + const sendParams = (sendStep.params as (v: Record) => Record)({ + chat: { domain: "chat", action: "createSession", result: { id: "session-new" } }, + }); + expect(sendParams).toMatchObject({ + arguments: { domain: "chat", action: "sendMessage", args: { sessionId: "session-new" } }, + }); + const sendArgs = (sendParams.arguments as { args: { text: string } }).args; + expect(sendArgs.text).toContain("ENG-431"); + expect(sendArgs.text).toContain("https://linear.app/x/ENG-431"); + }); + + it("builds a per-issue create_lane step for batch-create-from-linear", () => { + const plan = buildCliPlan([ + "lanes", + "batch-create-from-linear", + "--linear-issues-json", + '[{"id":"i1","identifier":"ENG-1","title":"A"},{"id":"i2","identifier":"ENG-2","title":"B"}]', + ]); + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + expect(plan.steps).toHaveLength(2); + // Each step is keyed by the issue identifier and tolerant of sibling failure. + expect(plan.steps[0]?.key).toBe("ENG-1"); + expect(plan.steps[0]?.optional).toBe(true); + expect(plan.steps[1]?.key).toBe("ENG-2"); + expect(plan.steps[0]?.params).toMatchObject({ + name: "create_lane", + arguments: { name: "A", linearIssue: { identifier: "ENG-1" } }, + }); + }); + + it("rejects --start-chat for the batch create path", () => { + expect(() => + buildCliPlan([ + "lanes", + "batch-create-from-linear", + "--issue-id", + "ENG-1", + "--start-chat", + ]), + ).toThrow(/creates lanes only/); + }); + + it("maps chat create --from-linear-issue to create + attach + kickoff", () => { + const plan = buildCliPlan([ + "chat", + "create", + "--lane", + "lane-1", + "--from-linear-issue", + "ENG-431", + ]); + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + expect(plan.steps).toHaveLength(3); + expect(plan.steps[0]?.key).toBe("session"); + + const attachStep = plan.steps[1]!; + const attachParams = (attachStep.params as (v: Record) => Record)({ + session: { domain: "chat", action: "createSession", result: { id: "session-x" } }, + }); + expect(attachParams).toMatchObject({ + arguments: { + domain: "lane", + action: "attachLinearIssueToSession", + args: { chatSessionId: "session-x", issues: [{ identifier: "ENG-431" }] }, + }, + }); + }); + + it("routes the linear write-bridge commands to linear_issue_tracker positional actions", () => { + const comment = buildCliPlan(["linear", "comment", "ENG-431", "All green"]); + expect(comment.kind).toBe("execute"); + if (comment.kind !== "execute") return; + expect(comment.steps[0]?.params).toEqual({ + name: "run_ade_action", + arguments: { + domain: "linear_issue_tracker", + action: "createComment", + argsList: ["ENG-431", "All green"], + }, + }); + + const setState = buildCliPlan(["linear", "set-state", "ENG-431", "state-done"]); + expect(setState.kind).toBe("execute"); + if (setState.kind !== "execute") return; + expect(setState.steps[0]?.params).toMatchObject({ + arguments: { action: "updateIssueState", argsList: ["ENG-431", "state-done"] }, + }); + + const assignNone = buildCliPlan(["linear", "assign", "ENG-431", "none"]); + expect(assignNone.kind).toBe("execute"); + if (assignNone.kind !== "execute") return; + expect(assignNone.steps[0]?.params).toMatchObject({ + arguments: { action: "updateIssueAssignee", argsList: ["ENG-431", null] }, + }); + + const label = buildCliPlan(["linear", "label", "ENG-431", "needs-review"]); + expect(label.kind).toBe("execute"); + if (label.kind !== "execute") return; + expect(label.steps[0]?.params).toMatchObject({ + arguments: { action: "addLabel", argsList: ["ENG-431", "needs-review"] }, + }); + }); + + it("defaults the linear comment issue id from ADE_LINEAR_ISSUE_IDS", () => { + const prev = process.env.ADE_LINEAR_ISSUE_IDS; + process.env.ADE_LINEAR_ISSUE_IDS = "ENG-900, ENG-901"; + try { + const plan = buildCliPlan(["linear", "comment", "done"]); + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + expect(plan.steps[0]?.params).toMatchObject({ + arguments: { action: "createComment", argsList: ["ENG-900", "done"] }, + }); + } finally { + if (prev === undefined) delete process.env.ADE_LINEAR_ISSUE_IDS; + else process.env.ADE_LINEAR_ISSUE_IDS = prev; + } + }); + + it("attaches an issue to the current session via linear attach --this-session", () => { + const prev = process.env.ADE_CHAT_SESSION_ID; + process.env.ADE_CHAT_SESSION_ID = "current-session"; + try { + const plan = buildCliPlan(["linear", "attach", "--this-session", "--issue-id", "ENG-431"]); + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + expect(plan.steps[0]?.params).toEqual({ + name: "run_ade_action", + arguments: { + domain: "lane", + action: "attachLinearIssueToSession", + args: { + chatSessionId: "current-session", + issues: [{ id: "ENG-431", identifier: "ENG-431" }], + }, + }, + }); + } finally { + if (prev === undefined) delete process.env.ADE_CHAT_SESSION_ID; + else process.env.ADE_CHAT_SESSION_ID = prev; + } + }); + + it("errors when linear attach --this-session has no session env", () => { + const prev = process.env.ADE_CHAT_SESSION_ID; + delete process.env.ADE_CHAT_SESSION_ID; + try { + expect(() => + buildCliPlan(["linear", "attach", "--this-session", "--issue-id", "ENG-1"]), + ).toThrow(/ADE_CHAT_SESSION_ID/); + } finally { + if (prev !== undefined) process.env.ADE_CHAT_SESSION_ID = prev; + } + }); + + it("rejects a Linear issue object missing both id and identifier", () => { + expect(() => + buildCliPlan([ + "lanes", + "attach-linear-issue", + "lane-1", + "--linear-issue-json", + '{"title":"no ids here"}', + ]), + ).toThrow(/missing both "id" and "identifier"/); + }); + it("maps Linear quick view to the typed RPC tool", () => { const plan = buildCliPlan(["linear", "quick-view", "--text"]); diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 5686c765e..cabb6c1de 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -923,7 +923,13 @@ const HELP_BY_COMMAND: Record = { $ ade lanes create --name Create a lane from the current project context $ ade lanes create --linear-issue-json '{...}' Create a lane linked to a Linear issue $ ade lanes link-linear-issue --linear-issue-json '{...}' - Link an existing lane to a Linear issue + Link an existing lane to a Linear issue (alias: attach-linear-issue) + $ ade lanes detach-linear-issue [--issue-id ENG-431] + Unlink one issue (or all non-primary links) from a lane + $ ade lanes create-from-linear --linear-issue-json '{...}' [--start-chat --provider codex --model ] + Create a lane from an issue, optionally auto-launching an agent + $ ade lanes batch-create-from-linear --linear-issues-json '[{...},{...}]' + Create one lane per issue (partial success, no orphans) $ ade lanes create --branch-name Override the auto-generated branch name $ ade lanes child --lane --name Create a child lane under a parent $ ade lanes import --branch Register an existing branch/worktree @@ -1105,7 +1111,13 @@ const HELP_BY_COMMAND: Record = { $ ade chat list --text List chat sessions $ ade chat create --lane --provider codex --model [--fast] + $ ade chat create --from-linear-issue ENG-431 Start a chat with an attached issue + kickoff (alias: --linear-issue-json) $ ade chat send --text "next step" Send a message + $ ade chat attach-linear-issue --issue-id ENG-431 + Attach a Linear issue to a chat/CLI session + $ ade chat detach-linear-issue [--issue-id ENG-431] + Detach one issue (or all) from a session + $ ade chat linear-issues --text List issues attached to a session $ ade chat interrupt Stop an active turn $ ade chat slash --text List slash commands for a session $ ade agent spawn --lane --prompt "fix" Start a new agent work session @@ -1388,6 +1400,25 @@ const HELP_BY_COMMAND: Record = { linear: `${ADE_BANNER} Linear workflows + Daemon bridge (for an agent running inside a tracked ADE CLI session): + these commands route over the ADE daemon to the desktop runtime, which holds + the Linear credentials — the CLI never needs a Linear token. When ADE launches + an agent with an attached issue it injects \$ADE_CHAT_SESSION_ID and + \$ADE_LINEAR_ISSUE_IDS, so the agent can read and write its issue with no ids. + + $ ade linear attach --this-session --issue-id ENG-431 + Attach an issue to the current CLI session + $ ade linear issues --this-session --text List issues attached to this session + $ ade linear issue ENG-431 --text Read one issue (defaults to the session's attached issue) + $ ade linear comment "Pushed a fix, running CI" Comment on the attached issue (or pass an id first) + $ ade linear comment ENG-431 "Done" Comment on a specific issue + $ ade linear set-state ENG-431 Move an issue to a workflow state + $ ade linear assign ENG-431 Assign or clear an issue assignee + $ ade linear label ENG-431 "needs-review" Add a label to an issue + $ ade linear detach --this-session [--issue-id ENG-431] + Detach one issue (or all) from this session + + Workspace + automation (typically run with --role cto): $ ade --role cto linear quick-view --text Show connected workspace, projects, and issues $ ade --role cto linear picker-data --text Read projects/users/states for the issue picker $ ade --role cto linear search-issues --query "auth" --state-type started,unstarted --first 50 @@ -1923,6 +1954,213 @@ function maybePut(target: JsonObject, key: string, value: unknown): void { } } +/** + * Foundation-owned daemon action names for Linear issue ↔ lane/session linking. + * Centralized here so any rename in the foundation registry is a one-line patch + * in the CLI. The lane domain owns lane-scoped and session-scoped attachment; + * the linear_issue_tracker domain owns the read/write bridge an attached CLI + * agent uses via `ade linear ...` (creds live in the desktop runtime). + */ +const LINEAR_ATTACH_ACTIONS = { + domain: "lane", + /** Lane-scoped link: linkLinearIssues({ laneId, issues: [...] }). */ + linkLane: "linkLinearIssues", + /** Lane-scoped unlink (issueId omitted = remove all non-primary links): unlinkLinearIssues({ laneId, issueId? }). */ + unlinkLane: "unlinkLinearIssues", + /** Attach issues to a session: attachLinearIssueToSession({ chatSessionId, issues: [...] }). */ + attachSession: "attachLinearIssueToSession", + /** Detach one issue (or all if issueId omitted): detachLinearIssueFromSession({ chatSessionId, issueId? }). */ + detachSession: "detachLinearIssueFromSession", + /** List issues for a session: listLinearIssuesForSession({ chatSessionId }). */ + listSession: "listLinearIssuesForSession", +} as const; + +/** + * Read the session id for session-scoped Linear commands. Prefers explicit + * flags, then the agent-environment session id ($ADE_CHAT_SESSION_ID) so an + * agent running inside a tracked CLI session can self-reference. `--this-session` + * forces the env path and errors if it is unset. + */ +function readSessionId(args: string[], options: { thisSession?: boolean } = {}): string | null { + const explicit = asString( + readValue(args, [ + "--chat-session", + "--chat-session-id", + "--session", + "--session-id", + ]), + ); + if (explicit) return explicit; + const envSession = asString(process.env.ADE_CHAT_SESSION_ID); + if (options.thisSession && !envSession) { + throw new CliUsageError( + "--this-session requires ADE_CHAT_SESSION_ID, which ADE sets inside a tracked CLI session.", + ); + } + return envSession; +} + +/** + * Parse `--linear-issue-json` (a single object or an array of objects) plus the + * `--issue-id`/`--linear-issue-id` shorthands (repeatable) into a normalized + * array of Linear issue objects. At least one issue must resolve. + */ +function parseLinearIssuesInput(args: string[], label = "--linear-issue-json"): JsonObject[] { + const issues: JsonObject[] = []; + const json = readValue(args, ["--linear-issue-json", "--issue-json", "--linear-issues-json"]); + if (json != null) { + const parsed = parseJson(json, label); + const candidates = Array.isArray(parsed) ? parsed : [parsed]; + if (candidates.length === 0 || candidates.some((entry) => !isRecord(entry))) { + throw new CliUsageError(`${label} must decode to an object or a non-empty array of objects.`); + } + issues.push(...(candidates as JsonObject[])); + } + // `--issue-id`/`--linear-issue-id` may be repeated; each becomes a minimal + // issue object the daemon can hydrate from its identifier. + const idFlags = ["--issue-id", "--linear-issue-id", "--issue", "--from-linear-issue"]; + let idShorthand = readValue(args, idFlags); + while (idShorthand != null) { + issues.push({ id: idShorthand, identifier: idShorthand }); + idShorthand = readValue(args, idFlags); + } + if (issues.length === 0) { + throw new CliUsageError(`${label} or --issue-id is required.`); + } + // Cheap pre-flight so the daemon never receives an unresolvable issue: every + // issue must carry an id or identifier. + const invalid = issues.findIndex( + (issue) => !asString(issue.id) && !asString(issue.identifier), + ); + if (invalid >= 0) { + throw new CliUsageError( + `${label} entry ${invalid + 1} is missing both "id" and "identifier".`, + ); + } + return issues; +} + +/** First Linear issue id ADE injected into the session via $ADE_LINEAR_ISSUE_IDS. */ +function sessionLinearIssueId(): string | null { + const envIds = asString(process.env.ADE_LINEAR_ISSUE_IDS); + return ( + envIds + ?.split(",") + .map((entry) => entry.trim()) + .find(Boolean) ?? null + ); +} + +/** Consume the single-issue-id flag (`--issue-id`/`--linear-issue-id`/`--issue`) from args. */ +function readIssueIdFlag(args: string[]): string | null { + return readValue(args, ["--issue-id", "--linear-issue-id", "--issue"]); +} + +/** + * Resolve a Linear write-bridge command's issue id when the command takes no + * positional value (e.g. `ade linear issue []`). Precedence: --issue-id flag + * → leading positional → the session's injected issue id. + */ +function requireLinearIssueId(args: string[]): string { + const explicit = asString(readIssueIdFlag(args)); + if (explicit) return explicit; + const positional = asString(firstPositional(args)); + if (positional) return positional; + const fromSession = sessionLinearIssueId(); + if (fromSession) return fromSession; + throw new CliUsageError( + "Linear issue id is required. Pass --issue-id or run inside a session with an attached issue (ADE sets $ADE_LINEAR_ISSUE_IDS).", + ); +} + +/** + * Resolve the issue id + a single positional value for write commands that take + * both (comment/set-state/label). Handles the ambiguity of one positional: + * - `comment ENG-431 "done"` → issueId=ENG-431, value="done" + * - `comment "done"` (session has an attached issue) → issueId from session, value="done" + * - `comment ENG-431` (no value flag) → issueId=ENG-431, value missing (caller errors) + * An explicit --issue-id flag or value flag always wins over positionals. + */ +function resolveLinearWriteCommand( + args: string[], + valueFlagNames: string[], +): { issueId: string; value: string | null } { + const explicitIssueId = asString(readIssueIdFlag(args)); + const explicitValue = asString(readValue(args, valueFlagNames)); + const positionals: string[] = []; + let next = firstPositional(args); + while (next != null) { + positionals.push(next); + next = firstPositional(args); + } + const sessionId = sessionLinearIssueId(); + + let issueId = explicitIssueId; + let value = explicitValue; + + if (!issueId) { + // With no explicit id: if the session injected one and exactly one positional + // remains for the value, treat that positional as the value. Otherwise the + // first positional is the issue id. + if (sessionId && (positionals.length <= 1 || explicitValue)) { + issueId = sessionId; + } else { + issueId = asString(positionals.shift() ?? null); + } + } + if (!issueId) { + throw new CliUsageError( + "Linear issue id is required. Pass --issue-id or run inside a session with an attached issue (ADE sets $ADE_LINEAR_ISSUE_IDS).", + ); + } + if (!value) { + value = positionals.length ? positionals.join(" ").trim() : null; + } + return { issueId, value }; +} + +/** Shared link/attachment flags (source, include-in-pr, close-on-merge, role). */ +function readLinearAttachmentFlags(args: string[]): JsonObject { + const flags: JsonObject = {}; + maybePut(flags, "role", readValue(args, ["--role"])); + maybePut(flags, "source", readValue(args, ["--source"])); + if (readFlag(args, ["--no-include-in-pr"])) flags.includeInPr = false; + if (readFlag(args, ["--include-in-pr"])) flags.includeInPr = true; + if (readFlag(args, ["--close-on-merge"])) flags.closeOnMerge = true; + if (readFlag(args, ["--no-close-on-merge"])) flags.closeOnMerge = false; + return flags; +} + +/** + * Build args for lane.attachLinearIssueToSession({ chatSessionId, issues: [...] }). + * The runtime accepts an array, so one or many issues attach in a single call. + */ +function buildSessionAttachArgs( + chatSessionId: string, + issues: JsonObject[], + flags: JsonObject, +): JsonObject { + return { chatSessionId, issues, ...flags }; +} + +/** + * Derive a kickoff prompt for a `--start-chat` / `--from-linear-issue` launch + * from the issue's identifier/title/url when the caller did not pass an explicit + * `--prompt`/`--kickoff`. Keeps the agent's first turn grounded in the issue. + */ +function deriveLinearKickoffPrompt(issue: JsonObject): string { + const identifier = asString(issue.identifier) ?? asString(issue.id) ?? "the linked issue"; + const title = asString(issue.title); + const url = asString(issue.url); + const lines = [ + `Work on Linear issue ${identifier}${title ? `: ${title}` : ""}.`, + "Read the attached issue context, then implement the change end-to-end.", + "Use `ade linear` to read comments and post status/comments back to the issue as you progress.", + ]; + if (url) lines.push(`Issue: ${url}`); + return lines.join("\n"); +} + /** * Parse the PR pipeline-settings flags shared by `prs path-to-merge` and * `prs pipeline` subcommands. Returns a partial `PipelineSettings` patch @@ -2424,42 +2662,90 @@ function buildLanePlan(args: string[]): CliPlan { steps: [actionArgsListStep("result", "lane", "getChildren", [laneId])], }; } - if (sub === "link-linear-issue" || sub === "link-linear" || sub === "linear-link") { + if ( + sub === "link-linear-issue" || + sub === "link-linear" || + sub === "linear-link" || + sub === "attach-linear-issue" || + sub === "attach-linear" + ) { + // `link-*` and `attach-*` are aliases for lane-scoped linking; the + // session-scoped attach lives under `ade chat attach-linear-issue`. const laneId = requireValue( readLaneId(args) ?? firstPositional(args), "laneId", ); - const linearIssueJson = requireValue( - readValue(args, ["--linear-issue-json", "--issue-json"]), - "--linear-issue-json", - ); - const parsed = parseJson(linearIssueJson, "--linear-issue-json"); - const issues = Array.isArray(parsed) ? parsed : [parsed]; - if (issues.length === 0 || issues.some((issue) => !isRecord(issue))) { - throw new CliUsageError("--linear-issue-json must decode to an object or array of objects."); - } + const issues = parseLinearIssuesInput(args); const input: JsonObject = { laneId, - issues: issues as JsonObject[], + issues, + ...readLinearAttachmentFlags(args), }; - maybePut(input, "role", readValue(args, ["--role"])); - maybePut(input, "source", readValue(args, ["--source"])); - if (readFlag(args, ["--no-include-in-pr"])) input.includeInPr = false; - if (readFlag(args, ["--include-in-pr"])) input.includeInPr = true; - if (readFlag(args, ["--close-on-merge"])) input.closeOnMerge = true; return { kind: "execute", label: "lane link Linear issue", steps: [ actionStep( "result", - "lane", - "linkLinearIssues", + LINEAR_ATTACH_ACTIONS.domain, + LINEAR_ATTACH_ACTIONS.linkLane, + collectGenericObjectArgs(args, input), + ), + ], + }; + } + if ( + sub === "detach-linear-issue" || + sub === "detach-linear" || + sub === "unlink-linear-issue" || + sub === "unlink-linear" + ) { + // Lane-scoped unlink: omitting --issue-id removes all non-primary lane links; + // the lane's primary (lane-create) issue is never removed by this action. + const laneId = requireValue( + readLaneId(args) ?? firstPositional(args), + "laneId", + ); + const issueId = asString(readIssueIdFlag(args)); + const input: JsonObject = { laneId }; + maybePut(input, "issueId", issueId); + return { + kind: "execute", + label: "lane detach Linear issue", + steps: [ + actionStep( + "result", + LINEAR_ATTACH_ACTIONS.domain, + LINEAR_ATTACH_ACTIONS.unlinkLane, collectGenericObjectArgs(args, input), ), ], }; } + if ( + sub === "create-from-linear" || + sub === "create-from-linear-issue" || + sub === "create-from-issue" + ) { + // Create a lane from a Linear issue, optionally auto-launching a chat agent + // grounded in the issue. Accepts a single issue (object or single-element + // array); batching across many issues is `ade lanes batch-create-from-linear`. + const issues = parseLinearIssuesInput(args); + if (issues.length !== 1) { + throw new CliUsageError( + "lanes create-from-linear expects exactly one issue. Use `ade lanes batch-create-from-linear` for multiple.", + ); + } + return buildCreateLaneFromLinearPlan(args, issues[0]!); + } + if ( + sub === "batch-create-from-linear" || + sub === "batch-create-from-linear-issue" || + sub === "batch-create-from-issue" + ) { + const issues = parseLinearIssuesInput(args, "--linear-issues-json"); + return buildBatchCreateLanesFromLinearPlan(args, issues); + } if (sub === "stack") { const laneId = requireValue( readLaneId(args) ?? firstPositional(args), @@ -2708,6 +2994,176 @@ function buildLanePlan(args: string[]): CliPlan { }; } +/** + * Read the model/provider/effort launch config shared by `--start-chat` and + * `ade chat create`. Returns only the keys the caller explicitly set so the + * daemon can fall back to its own defaults. + */ +function readChatLaunchConfig(args: string[]): JsonObject { + const modelArg = readValue(args, ["--model", "--model-id"]); + const fastRequested = readFlag(args, ["--fast", "--codex-fast"]); + const standardRequested = readFlag(args, ["--standard", "--no-fast", "--no-codex-fast"]); + if (fastRequested && standardRequested) { + throw new CliUsageError( + "Use either --fast/--codex-fast or --standard/--no-fast/--no-codex-fast, not both.", + ); + } + const config: JsonObject = {}; + maybePut(config, "provider", readValue(args, ["--provider"])); + maybePut(config, "model", modelArg); + maybePut(config, "modelId", modelArg); + maybePut(config, "reasoningEffort", readValue(args, ["--reasoning-effort", "--effort"])); + maybePut( + config, + "permissionMode", + readValue(args, ["--permission-mode", "--permissions"]), + ); + if (fastRequested) config.codexFastMode = true; + if (standardRequested) config.codexFastMode = false; + return config; +} + +/** + * Build a `lanes create-from-linear` plan: create a lane linked to the issue and, + * when `--start-chat` is set, chain a chat session + an issue-grounded kickoff + * message. Steps share results through the executor's `values` map (step.key), + * so the chat session is created against the lane that step one just made. + */ +function buildCreateLaneFromLinearPlan(args: string[], issue: JsonObject): CliPlan { + const explicitName = readValue(args, ["--name"]); + const derivedName = + asString(issue.title) ?? + asString(issue.identifier) ?? + asString(issue.id) ?? + "Linear lane"; + const createInput: JsonObject = { + name: explicitName ?? derivedName, + linearIssue: issue, + }; + maybePut(createInput, "description", readValue(args, ["--description", "--desc"])); + maybePut(createInput, "baseBranch", readValue(args, ["--base", "--base-branch"])); + maybePut(createInput, "branchName", readValue(args, ["--branch-name"])); + maybePut(createInput, "parentLaneId", readValue(args, ["--parent", "--parent-lane", "--parent-lane-id"])); + + const startChat = readFlag(args, ["--start-chat", "--launch", "--start-agent"]); + const kickoff = + readValue(args, ["--prompt", "--kickoff", "--kickoff-prompt"]) ?? + deriveLinearKickoffPrompt(issue); + // Launch config is read before collectGenericObjectArgs sees the lane-create + // args so `--provider`/`--model`/`--fast` go to the chat, not the lane. + const launchConfig = startChat ? readChatLaunchConfig(args) : {}; + const surface = startChat ? readValue(args, ["--surface"]) ?? "work" : null; + + const steps: InvocationStep[] = [ + actionCallStep("lane", "create_lane", collectGenericObjectArgs(args, createInput)), + ]; + + if (startChat) { + steps.push({ + key: "chat", + method: "ade/actions/call", + params: (values) => { + const laneId = laneIdFromCreateLaneValue(values.lane); + if (!laneId) { + throw new CliUsageError("create-from-linear could not resolve the new lane id to launch a chat."); + } + return { + name: "run_ade_action", + arguments: { + domain: "chat", + action: "createSession", + args: { laneId, surface, ...launchConfig }, + }, + }; + }, + unwrapToolResult: true, + }); + steps.push({ + key: "result", + method: "ade/actions/call", + params: (values) => { + const sessionId = sessionIdFromCreateChatValue(values.chat); + if (!sessionId) { + throw new CliUsageError("create-from-linear launched a chat but could not resolve its session id."); + } + return { + name: "run_ade_action", + arguments: { + domain: "chat", + action: "sendMessage", + args: { sessionId, text: kickoff }, + }, + }; + }, + unwrapToolResult: true, + }); + } + + return { + kind: "execute", + label: startChat ? "lane create-from-linear + chat" : "lane create-from-linear", + steps, + }; +} + +/** + * Build a batch `lanes batch-create-from-linear` plan: one create_lane step per + * issue. Failures are isolated (`optional: true`) so a bad issue does not orphan + * the lanes already created for its siblings — mirroring the renderer's + * bounded-parallel "partial success, no orphans" contract. Each step is keyed by + * the issue identifier so the JSON output reports per-issue success/failure. + * `--start-chat` is intentionally rejected here: auto-launching N agents belongs + * to the desktop BatchLaunchModal; the CLI batch path only creates lanes. + */ +function buildBatchCreateLanesFromLinearPlan(args: string[], issues: JsonObject[]): CliPlan { + if (readFlag(args, ["--start-chat", "--launch", "--start-agent"])) { + throw new CliUsageError( + "batch-create-from-linear creates lanes only. Use the desktop launch modal, or `ade lanes create-from-linear --start-chat` per issue, to auto-launch agents.", + ); + } + const baseBranch = readValue(args, ["--base", "--base-branch"]); + const parentLaneId = readValue(args, ["--parent", "--parent-lane", "--parent-lane-id"]); + const steps: InvocationStep[] = issues.map((issue, index) => { + const key = + asString(issue.identifier) ?? asString(issue.id) ?? `issue-${index + 1}`; + const createInput: JsonObject = { + name: + asString(issue.title) ?? + asString(issue.identifier) ?? + asString(issue.id) ?? + `Linear lane ${index + 1}`, + linearIssue: issue, + }; + maybePut(createInput, "baseBranch", baseBranch); + maybePut(createInput, "parentLaneId", parentLaneId); + return { + ...actionCallStep(key, "create_lane", createInput), + optional: true, + }; + }); + return { + kind: "execute", + label: `lane batch-create-from-linear (${issues.length})`, + steps, + }; +} + +/** Extract a lane id from an unwrapped `create_lane` run_ade_action result. */ +function laneIdFromCreateLaneValue(value: unknown): string | null { + const result = unwrapActionEnvelope(value); + if (!isRecord(result)) return null; + const lane = isRecord(result.lane) ? result.lane : result; + return asString(lane.id) ?? asString(lane.laneId); +} + +/** Extract a session id from an unwrapped `chat.createSession` result. */ +function sessionIdFromCreateChatValue(value: unknown): string | null { + const result = unwrapActionEnvelope(value); + if (!isRecord(result)) return null; + const session = isRecord(result.session) ? result.session : result; + return asString(session.id) ?? asString(session.sessionId); +} + function resolveStashSelectionForCli(listResult: unknown, stashRef: string | null, stashOid: string | null): { stashRef: string; stashOid: string; @@ -4802,9 +5258,24 @@ function buildChatPlan(args: string[]): CliPlan { label: "chat actions", steps: [listActionsStep("actions", "chat")], }; + // Linear session-scoped subcommands resolve their own session id AFTER + // consuming --issue-id (so firstPositional can't mistake an issue id flag value + // for the session), so they opt out of the shared positional grab here. + const linearSessionSub = + sub === "attach-linear-issue" || + sub === "attach-linear" || + sub === "attach-issue" || + sub === "detach-linear-issue" || + sub === "detach-linear" || + sub === "detach-issue" || + sub === "linear-issues" || + sub === "list-linear-issues" || + sub === "issues"; const sessionId = readValue(args, ["--session", "--session-id"]) ?? - (sub !== "create" && sub !== "list" ? firstPositional(args) : null); + (sub !== "create" && sub !== "list" && !linearSessionSub + ? firstPositional(args) + : null); const withSession = (base: JsonObject = {}) => collectGenericObjectArgs(args, { ...base, @@ -4833,6 +5304,87 @@ function buildChatPlan(args: string[]): CliPlan { ]), ], }; + if ( + sub === "attach-linear-issue" || + sub === "attach-linear" || + sub === "attach-issue" + ) { + // Session-scoped attach: links an issue to a chat / CLI session (standalone + // or lane-backed). The agent inside the session then reads it via injected + // context and reads/writes back through `ade linear ...`. Parse issues first + // so --issue-id is consumed before the positional session id is resolved. + const issues = parseLinearIssuesInput(args); + const targetSession = requireValue( + sessionId ?? firstPositional(args) ?? readSessionId(args), + "sessionId", + ); + const input = buildSessionAttachArgs( + targetSession, + issues, + readLinearAttachmentFlags(args), + ); + return { + kind: "execute", + label: "chat attach Linear issue", + steps: [ + actionStep( + "result", + LINEAR_ATTACH_ACTIONS.domain, + LINEAR_ATTACH_ACTIONS.attachSession, + collectGenericObjectArgs(args, input), + ), + ], + }; + } + if ( + sub === "detach-linear-issue" || + sub === "detach-linear" || + sub === "detach-issue" + ) { + // Consume --issue-id before resolving the positional session id. Omitting + // --issue-id detaches every issue from the session. + const issueId = asString(readIssueIdFlag(args)); + const targetSession = requireValue( + sessionId ?? firstPositional(args) ?? readSessionId(args), + "sessionId", + ); + const input: JsonObject = { chatSessionId: targetSession }; + maybePut(input, "issueId", issueId); + return { + kind: "execute", + label: "chat detach Linear issue", + steps: [ + actionStep( + "result", + LINEAR_ATTACH_ACTIONS.domain, + LINEAR_ATTACH_ACTIONS.detachSession, + collectGenericObjectArgs(args, input), + ), + ], + }; + } + if ( + sub === "linear-issues" || + sub === "list-linear-issues" || + sub === "issues" + ) { + const targetSession = requireValue( + sessionId ?? firstPositional(args) ?? readSessionId(args), + "sessionId", + ); + return { + kind: "execute", + label: "chat Linear issues", + steps: [ + actionStep( + "result", + LINEAR_ATTACH_ACTIONS.domain, + LINEAR_ATTACH_ACTIONS.listSession, + collectGenericObjectArgs(args, { chatSessionId: targetSession }), + ), + ], + }; + } if (sub === "create" || sub === "spawn") { const modelArg = readValue(args, ["--model", "--model-id"]); const fastRequested = readFlag(args, ["--fast", "--codex-fast"]); @@ -4855,36 +5407,104 @@ function buildChatPlan(args: string[]): CliPlan { // print-mode (suppresses delta notification streams). Must be set at create // time because the handshake runs once when the runtime starts. const createRuntimeMode = readFlag(args, ["--print"]) ? "print" : undefined; - return { - kind: "execute", - label: "chat create", - steps: [ - actionStep( - "result", - "chat", - "createSession", - collectGenericObjectArgs(args, { - laneId: readLaneId(args), - provider: readValue(args, ["--provider"]), - model: modelArg, - modelId: modelArg, - permissionMode: readValue(args, [ - "--permission-mode", - "--permissions", - ]), - droidPermissionMode: readValue(args, [ - "--droid-permission-mode", - "--droid-autonomy", - "--autonomy", - ]), - title: readValue(args, ["--title"]), - surface: readValue(args, ["--surface"]) ?? "work", - ...(codexFastMode !== undefined ? { codexFastMode } : {}), - ...(createRuntimeMode ? { runtimeMode: createRuntimeMode } : {}), - }), - ), - ], - }; + // `--from-linear-issue ` / `--linear-issue-json` start the chat with an + // attached issue: create the session, attach the issue to it, then send an + // issue-grounded kickoff (skipped with --no-kickoff). Read these before + // collectGenericObjectArgs consumes the remaining args. + const fromLinear = + args.some((t) => + t === "--from-linear-issue" || + t.startsWith("--from-linear-issue=") || + t === "--linear-issue-json" || + t.startsWith("--linear-issue-json="), + ); + let linearIssue: JsonObject | null = null; + if (fromLinear) { + const issues = parseLinearIssuesInput(args, "--from-linear-issue"); + if (issues.length !== 1) { + throw new CliUsageError("chat create accepts exactly one Linear issue to attach."); + } + linearIssue = issues[0]!; + } + const noKickoff = readFlag(args, ["--no-kickoff"]); + const explicitKickoff = readValue(args, ["--prompt", "--kickoff", "--kickoff-prompt"]); + const attachmentFlags = linearIssue ? readLinearAttachmentFlags(args) : {}; + const createStep = actionStep( + "result", + "chat", + "createSession", + collectGenericObjectArgs(args, { + laneId: readLaneId(args), + provider: readValue(args, ["--provider"]), + model: modelArg, + modelId: modelArg, + permissionMode: readValue(args, [ + "--permission-mode", + "--permissions", + ]), + droidPermissionMode: readValue(args, [ + "--droid-permission-mode", + "--droid-autonomy", + "--autonomy", + ]), + title: readValue(args, ["--title"]), + surface: readValue(args, ["--surface"]) ?? "work", + ...(codexFastMode !== undefined ? { codexFastMode } : {}), + ...(createRuntimeMode ? { runtimeMode: createRuntimeMode } : {}), + }), + ); + if (!linearIssue) { + return { kind: "execute", label: "chat create", steps: [createStep] }; + } + const issueForKickoff = linearIssue; + const steps: InvocationStep[] = [ + // First step keyed "session" so attach/kickoff can read the new id. + { ...createStep, key: "session" }, + { + key: noKickoff ? "result" : "attach", + method: "ade/actions/call", + params: (values) => { + const targetSession = sessionIdFromCreateChatValue(values.session); + if (!targetSession) { + throw new CliUsageError("chat create could not resolve the new session id to attach the issue."); + } + return { + name: "run_ade_action", + arguments: { + domain: LINEAR_ATTACH_ACTIONS.domain, + action: LINEAR_ATTACH_ACTIONS.attachSession, + args: { chatSessionId: targetSession, issues: [issueForKickoff], ...attachmentFlags }, + }, + }; + }, + unwrapToolResult: true, + }, + ]; + if (!noKickoff) { + steps.push({ + key: "result", + method: "ade/actions/call", + params: (values) => { + const targetSession = sessionIdFromCreateChatValue(values.session); + if (!targetSession) { + throw new CliUsageError("chat create could not resolve the new session id to send the kickoff."); + } + return { + name: "run_ade_action", + arguments: { + domain: "chat", + action: "sendMessage", + args: { + sessionId: targetSession, + text: explicitKickoff ?? deriveLinearKickoffPrompt(issueForKickoff), + }, + }, + }; + }, + unwrapToolResult: true, + }); + } + return { kind: "execute", label: "chat create from Linear issue", steps }; } if (sub === "send") { const imageUrl = readValue(args, ["--image-url"]); @@ -8010,6 +8630,152 @@ function buildAutomationsPlan(args: string[]): CliPlan { function buildLinearPlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "workflows"; + // --- Daemon-bridge commands for a CLI-session agent --- + // These let an agent running inside a tracked ADE CLI session read and write + // its attached Linear issue without holding Linear credentials: every call is + // routed over the daemon to the desktop runtime, which owns the creds. The + // issue id defaults to the session's first attached issue ($ADE_LINEAR_ISSUE_IDS) + // so an agent can run `ade linear comment "done"` with no id. + if ( + sub === "attach" || + sub === "attach-issue" || + sub === "attach-linear-issue" + ) { + // `ade linear attach --this-session` attaches an issue to the current CLI + // session (the agent's own session). Explicit --session/--lane override. + const thisSession = readFlag(args, ["--this-session", "--self", "--current-session"]); + const laneId = readLaneId(args); + const targetSession = readSessionId(args, { thisSession }); + const issues = parseLinearIssuesInput(args); + const flags = readLinearAttachmentFlags(args); + if (laneId && !targetSession) { + // No session in scope: fall back to a lane-scoped link. + return { + kind: "execute", + label: "linear attach (lane)", + steps: [ + actionStep( + "result", + LINEAR_ATTACH_ACTIONS.domain, + LINEAR_ATTACH_ACTIONS.linkLane, + collectGenericObjectArgs(args, { laneId, issues, ...flags }), + ), + ], + }; + } + const sessionId = requireValue( + targetSession, + thisSession ? "ADE_CHAT_SESSION_ID" : "session id (use --this-session, --session , or --lane )", + ); + return { + kind: "execute", + label: "linear attach (session)", + steps: [ + actionStep( + "result", + LINEAR_ATTACH_ACTIONS.domain, + LINEAR_ATTACH_ACTIONS.attachSession, + collectGenericObjectArgs(args, buildSessionAttachArgs(sessionId, issues, flags)), + ), + ], + }; + } + if (sub === "detach" || sub === "detach-issue" || sub === "detach-linear-issue") { + const thisSession = readFlag(args, ["--this-session", "--self", "--current-session"]); + const chatSessionId = requireValue( + readSessionId(args, { thisSession }), + thisSession ? "ADE_CHAT_SESSION_ID" : "session id (use --this-session or --session )", + ); + // Omitting an issue id detaches every issue from the session; default a + // positional / the session's injected issue when one is available. + const issueId = asString( + readIssueIdFlag(args) ?? firstPositional(args) ?? sessionLinearIssueId(), + ); + const input: JsonObject = { chatSessionId }; + maybePut(input, "issueId", issueId); + return { + kind: "execute", + label: "linear detach (session)", + steps: [ + actionStep( + "result", + LINEAR_ATTACH_ACTIONS.domain, + LINEAR_ATTACH_ACTIONS.detachSession, + collectGenericObjectArgs(args, input), + ), + ], + }; + } + if (sub === "issues" || sub === "attached" || sub === "my-issues") { + // `issues` always targets the current session; consume the alias flags so + // they don't linger, but the session id always resolves via --session/env. + readFlag(args, ["--this-session", "--self", "--current-session"]); + const chatSessionId = requireValue( + readSessionId(args, { thisSession: true }), + "ADE_CHAT_SESSION_ID", + ); + return { + kind: "execute", + label: "linear attached issues", + steps: [ + actionStep( + "result", + LINEAR_ATTACH_ACTIONS.domain, + LINEAR_ATTACH_ACTIONS.listSession, + collectGenericObjectArgs(args, { chatSessionId }), + ), + ], + }; + } + if (sub === "comment") { + const { issueId, value } = resolveLinearWriteCommand(args, ["--body", "--text", "-m", "--message"]); + const body = requireValue(value, "comment body"); + return { + kind: "execute", + label: "linear comment", + steps: [actionArgsListStep("result", "linear_issue_tracker", "createComment", [issueId, body])], + }; + } + if (sub === "set-state" || sub === "status" || sub === "state" || sub === "move") { + const { issueId, value } = resolveLinearWriteCommand(args, ["--state-id", "--state", "--status"]); + const stateId = requireValue(value, "state id"); + return { + kind: "execute", + label: "linear set-state", + steps: [actionArgsListStep("result", "linear_issue_tracker", "updateIssueState", [issueId, stateId])], + }; + } + if (sub === "assign") { + const { issueId, value } = resolveLinearWriteCommand(args, ["--assignee", "--assignee-id", "--user"]); + // `none`/`null`/`unassigned` (or an omitted assignee) clears the assignee. + const normalized = (value ?? "").trim().toLowerCase(); + const assigneeId = + value == null || normalized === "none" || normalized === "null" || normalized === "unassigned" + ? null + : value.trim(); + return { + kind: "execute", + label: "linear assign", + steps: [actionArgsListStep("result", "linear_issue_tracker", "updateIssueAssignee", [issueId, assigneeId])], + }; + } + if (sub === "label" || sub === "add-label") { + const { issueId, value } = resolveLinearWriteCommand(args, ["--label", "--label-name", "--name"]); + const labelName = requireValue(value, "label name"); + return { + kind: "execute", + label: "linear add-label", + steps: [actionArgsListStep("result", "linear_issue_tracker", "addLabel", [issueId, labelName])], + }; + } + if (sub === "issue" || sub === "show-issue" || sub === "get-issue") { + const issueId = requireLinearIssueId(args); + return { + kind: "execute", + label: "linear issue", + steps: [actionArgsListStep("result", "linear_issue_tracker", "fetchIssueById", [issueId])], + }; + } if (sub === "quick-view" || sub === "quick" || sub === "overview") { return { kind: "execute", diff --git a/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts b/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts index 1c3feec6c..1c8e1a1fd 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts @@ -356,10 +356,92 @@ describe("linear command routing", () => { }); it("shows usage for bare /linear instead of running a default tool", () => { - expect(buildLinearToolRequest("")).toEqual({ - kind: "usage", - title: "Linear", - body: "Usage: /linear ...", + const result = buildLinearToolRequest(""); + expect(result.kind).toBe("usage"); + if (result.kind !== "usage") return; + expect(result.title).toBe("Linear"); + expect(result.body).toContain("attach"); + expect(result.body).toContain("comment"); + expect(result.body).toContain("workflows"); + }); + + it("routes /linear attach to a session attach action with the active-session placeholder", () => { + expect(buildLinearToolRequest("attach --issue-id ENG-431")).toEqual({ + kind: "action", + title: "Linear attach", + domain: "lane", + action: "attachLinearIssueToSession", + args: { chatSessionId: "__ACTIVE_SESSION__", issues: [{ id: "ENG-431", identifier: "ENG-431" }] }, + }); + }); + + it("routes /linear attach --lane to a lane-scoped link", () => { + expect(buildLinearToolRequest("attach --issue-id ENG-431 --lane lane-1")).toEqual({ + kind: "action", + title: "Linear attach (lane)", + domain: "lane", + action: "linkLinearIssues", + args: { laneId: "lane-1", issues: [{ id: "ENG-431", identifier: "ENG-431" }] }, + }); + }); + + it("routes /linear detach, with or without a specific issue id", () => { + expect(buildLinearToolRequest("detach ENG-431 --session s1")).toEqual({ + kind: "action", + title: "Linear detach", + domain: "lane", + action: "detachLinearIssueFromSession", + args: { chatSessionId: "s1", issueId: "ENG-431" }, + }); + // No issue id: detach-all (issueId omitted by compactArgs). + expect(buildLinearToolRequest("detach --session s1")).toEqual({ + kind: "action", + title: "Linear detach", + domain: "lane", + action: "detachLinearIssueFromSession", + args: { chatSessionId: "s1" }, + }); + // --lane (no session) routes to the lane-scoped unlink action. + expect(buildLinearToolRequest("detach --lane lane-1")).toEqual({ + kind: "action", + title: "Linear detach (lane)", + domain: "lane", + action: "unlinkLinearIssues", + args: { laneId: "lane-1" }, + }); + }); + + it("routes /linear issues to a session list action", () => { + expect(buildLinearToolRequest("issues")).toEqual({ + kind: "action", + title: "Linear attached issues", + domain: "lane", + action: "listLinearIssuesForSession", + args: { chatSessionId: "__ACTIVE_SESSION__" }, + }); + }); + + it("routes the /linear write-bridge commands to linear_issue_tracker positional actions", () => { + expect(buildLinearToolRequest("comment ENG-431 done")).toEqual({ + kind: "actionList", + title: "Linear comment", + domain: "linear_issue_tracker", + action: "createComment", + argsList: ["ENG-431", "done"], + }); + expect(buildLinearToolRequest("set-state ENG-431 state-done")).toEqual({ + kind: "actionList", + title: "Linear set-state", + domain: "linear_issue_tracker", + action: "updateIssueState", + argsList: ["ENG-431", "state-done"], + }); + expect(buildLinearToolRequest("assign ENG-431 none")).toEqual({ + kind: "actionList", + title: "Linear assign", + domain: "linear_issue_tracker", + action: "updateIssueAssignee", + argsList: ["ENG-431", null], }); }); diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index 9efb90e99..232c9ca90 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -140,7 +140,7 @@ import { isImageFilePath, latestOpenableImageTarget, readClipboardImageAttachmen import { appendReservedTuiEvent, dedupeTuiEvents, reserveTuiEventDedupKey, syncTuiEventDedupKeys } from "./eventDedup"; import { loadAdeCodeState, saveAdeCodeProjectState, scopedAdeCodeState } from "./state"; import { SpinTickProvider } from "./spinTick"; -import { buildLinearToolRequest } from "./linearCommands"; +import { ACTIVE_SESSION_PLACEHOLDER, buildLinearToolRequest } from "./linearCommands"; import { formatLinearIssueComments, formatLinearStatus, @@ -7284,6 +7284,34 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } setRightPane({ kind: "details", title: request.title, body: "Loading Linear data..." }); + if (request.kind === "action") { + // Session-scoped attach/detach/list default to the active chat session: + // the ACTIVE_SESSION_PLACEHOLDER sentinel on chatSessionId is substituted + // with the live id. + const actionArgs = { ...request.args }; + if (actionArgs.chatSessionId === ACTIVE_SESSION_PLACEHOLDER) { + if (!sessionId) { + setRightPane({ kind: "details", title: request.title, body: "No active chat session. Pass --session ." }); + return; + } + actionArgs.chatSessionId = sessionId; + } + const result = await conn.action(request.domain, request.action, actionArgs); + setRightPane({ kind: "details", title: request.title, body: renderObject(result, 24) }); + return; + } + if (request.kind === "actionList") { + const argsList = request.argsList.map((entry) => + entry === ACTIVE_SESSION_PLACEHOLDER ? sessionId : entry, + ); + if (argsList.some((entry) => entry == null)) { + setRightPane({ kind: "details", title: request.title, body: "No active chat session. Pass --session ." }); + return; + } + const result = await conn.actionList(request.domain, request.action, argsList); + setRightPane({ kind: "details", title: request.title, body: renderObject(result, 24) }); + return; + } const result = await conn.tool(request.toolName, request.args); setRightPane({ kind: "details", title: request.title, body: renderObject(result, 24) }); return; diff --git a/apps/ade-cli/src/tuiClient/linearCommands.ts b/apps/ade-cli/src/tuiClient/linearCommands.ts index f778fd456..a67a4f901 100644 --- a/apps/ade-cli/src/tuiClient/linearCommands.ts +++ b/apps/ade-cli/src/tuiClient/linearCommands.ts @@ -5,12 +5,39 @@ export type LinearToolRequest = toolName: string; args: Record; } + | { + // run_ade_action with object args (domain.action). Used by attach/detach + // and the issue write-bridge, which live on the lane/linear_issue_tracker + // domains rather than the flat Linear tool names. + kind: "action"; + title: string; + domain: string; + action: string; + args: Record; + } + | { + // run_ade_action with positional args (argsList). The Linear issue tracker + // write methods (createComment, updateIssueState, ...) take positionals. + kind: "actionList"; + title: string; + domain: string; + action: string; + argsList: unknown[]; + } | { kind: "usage"; title: string; body: string; }; +/** + * Sentinel the dispatcher replaces with the active chat session id. Session-scoped + * Linear commands default to the active session when one is not named explicitly; + * linearCommands.ts has no access to UI state, so it emits this placeholder and + * the /linear dispatcher in app.tsx substitutes the live id. + */ +export const ACTIVE_SESSION_PLACEHOLDER = "__ACTIVE_SESSION__"; + type ParsedArgs = { positionals: string[]; options: Record; @@ -84,19 +111,193 @@ function tool(title: string, toolName: string, args: Record = { return { kind: "tool", title, toolName, args: compactArgs(args) }; } +function action(title: string, domain: string, name: string, args: Record = {}): LinearToolRequest { + return { kind: "action", title, domain, action: name, args: compactArgs(args) }; +} + +function actionList(title: string, domain: string, name: string, argsList: unknown[]): LinearToolRequest { + return { kind: "actionList", title, domain, action: name, argsList }; +} + +/** + * Resolve issue objects for attach from `--issue-id ` or `--linear-issue-json` + * (object or array). Returns null when neither is present. + */ +function parseIssuesOption(options: Record): Record[] | null { + const json = options.linearIssueJson ?? options.issueJson ?? options.linearIssuesJson; + if (typeof json === "string") { + try { + const parsed = JSON.parse(json) as unknown; + const candidates = Array.isArray(parsed) ? parsed : [parsed]; + const issues = candidates.filter( + (entry): entry is Record => + Boolean(entry) && typeof entry === "object" && !Array.isArray(entry), + ); + if (issues.length) return issues; + } catch { + return null; + } + } + const id = optionString(options, "issueId", "issue", "linearIssueId"); + if (id) return [{ id, identifier: id }]; + return null; +} + +/** Issue id for the write-bridge commands: `--issue-id`/`--issue` flag, else the leading positional. */ +function writeCommandIssueId(options: Record, modeArg: string | undefined): string | null { + return optionString(options, "issueId", "issue") ?? modeArg ?? null; +} + +/** Shared attachment flags (source, includeInPr, closeOnMerge, role). */ +function attachmentFlags(options: Record): Record { + return compactArgs({ + role: optionString(options, "role") ?? undefined, + source: optionString(options, "source") ?? undefined, + includeInPr: + optionBoolean(options, "noIncludeInPr") === true + ? false + : optionBoolean(options, "includeInPr"), + closeOnMerge: optionBoolean(options, "closeOnMerge"), + }); +} + export function buildLinearToolRequest(input: string): LinearToolRequest { const parsed = parseLinearArgs(input); const [group, modeArg, ...rest] = parsed.positionals; const options = parsed.options; if (!group) { - return usage("Linear", "Usage: /linear ..."); + return usage( + "Linear", + "Usage: /linear ...", + ); } if (group === "workflows") { return tool("Linear workflows", "listLinearWorkflows"); } + if (group === "attach" || group === "attach-issue" || group === "attach-linear-issue") { + // Attach an issue to a session (default: the active chat) or a lane. + const issues = parseIssuesOption(options); + if (!issues) { + return usage( + "Linear attach", + "Usage: /linear attach --issue-id [--session ] [--lane ] (defaults to the active chat session)", + ); + } + const laneId = optionString(options, "laneId", "lane"); + const sessionId = optionString(options, "session", "sessionId", "chatSession", "chatSessionId"); + if (laneId && !sessionId) { + return action("Linear attach (lane)", "lane", "linkLinearIssues", { + laneId, + issues, + ...attachmentFlags(options), + }); + } + // attachLinearIssueToSession takes an issues array keyed by chatSessionId. A + // missing chatSessionId is filled with the active session by the dispatcher. + return action("Linear attach", "lane", "attachLinearIssueToSession", { + chatSessionId: sessionId ?? ACTIVE_SESSION_PLACEHOLDER, + issues, + ...attachmentFlags(options), + }); + } + + if (group === "detach" || group === "detach-issue" || group === "detach-linear-issue") { + const laneId = optionString(options, "laneId", "lane"); + const sessionId = optionString(options, "session", "sessionId", "chatSession", "chatSessionId"); + // Omitting an issue id detaches all (non-primary for a lane; every issue for a session). + const issueId = optionString(options, "issueId", "issue", "linearIssueId") ?? modeArg ?? rest[0] ?? null; + if (laneId && !sessionId) { + return action("Linear detach (lane)", "lane", "unlinkLinearIssues", { + laneId, + issueId: issueId ?? undefined, + }); + } + return action("Linear detach", "lane", "detachLinearIssueFromSession", { + chatSessionId: sessionId ?? ACTIVE_SESSION_PLACEHOLDER, + issueId: issueId ?? undefined, + }); + } + + if (group === "issues" || group === "attached") { + const sessionId = optionString(options, "session", "sessionId", "chatSession", "chatSessionId"); + return action("Linear attached issues", "lane", "listLinearIssuesForSession", { + chatSessionId: sessionId ?? ACTIVE_SESSION_PLACEHOLDER, + }); + } + + if (group === "comment") { + const issueId = writeCommandIssueId(options, modeArg); + const body = optionString(options, "body", "text", "message") ?? rest.join(" ").trim(); + if (!issueId || !body) { + return usage("Linear comment", "Usage: /linear comment "); + } + return actionList("Linear comment", "linear_issue_tracker", "createComment", [issueId, body]); + } + + if (group === "set-state" || group === "status" || group === "state" || group === "move") { + const issueId = writeCommandIssueId(options, modeArg); + const stateId = optionString(options, "stateId", "state", "status") ?? rest[0] ?? null; + if (!issueId || !stateId) { + return usage("Linear set-state", "Usage: /linear set-state "); + } + return actionList("Linear set-state", "linear_issue_tracker", "updateIssueState", [issueId, stateId]); + } + + if (group === "assign") { + const issueId = writeCommandIssueId(options, modeArg); + if (!issueId) return usage("Linear assign", "Usage: /linear assign "); + const rawAssignee = optionString(options, "assignee", "assigneeId", "user") ?? rest[0] ?? null; + const normalized = (rawAssignee ?? "").toLowerCase(); + const assigneeId = + !rawAssignee || normalized === "none" || normalized === "null" || normalized === "unassigned" + ? null + : rawAssignee; + return actionList("Linear assign", "linear_issue_tracker", "updateIssueAssignee", [issueId, assigneeId]); + } + + if (group === "label" || group === "add-label") { + const issueId = writeCommandIssueId(options, modeArg); + const labelName = optionString(options, "label", "labelName", "name") ?? rest[0] ?? null; + if (!issueId || !labelName) { + return usage("Linear label", "Usage: /linear label "); + } + return actionList("Linear add-label", "linear_issue_tracker", "addLabel", [issueId, labelName]); + } + + if (group === "issue" || group === "show-issue" || group === "get-issue") { + const issueId = writeCommandIssueId(options, modeArg); + if (!issueId) return usage("Linear issue", "Usage: /linear issue "); + return actionList("Linear issue", "linear_issue_tracker", "fetchIssueById", [issueId]); + } + + if (group === "create-from" || group === "create-from-linear" || group === "create-lane-from") { + // Create a lane from an issue. Auto-launching an agent from the TUI is out of + // scope here (the desktop launch modal owns that); the CLI offers --start-chat. + const issues = parseIssuesOption(options); + if (!issues || issues.length !== 1) { + return usage( + "Linear create-from", + "Usage: /linear create-from --issue-id [--name ] [--base ]", + ); + } + const issue = issues[0]!; + const name = + optionString(options, "name") ?? + (typeof issue.title === "string" ? issue.title : null) ?? + (typeof issue.identifier === "string" ? issue.identifier : null) ?? + (typeof issue.id === "string" ? issue.id : null) ?? + "Linear lane"; + return tool("Linear create lane", "create_lane", compactArgs({ + name, + linearIssue: issue, + baseBranch: optionString(options, "base", "baseBranch") ?? undefined, + branchName: optionString(options, "branchName") ?? undefined, + })); + } + if (group === "run") { const mode = modeArg ?? "status"; const runId = optionString(options, "runId", "run") ?? rest[0] ?? null; @@ -193,5 +394,8 @@ export function buildLinearToolRequest(input: string): LinearToolRequest { return usage("Linear ingress", "Usage: /linear ingress "); } - return usage("Linear", "Usage: /linear ..."); + return usage( + "Linear", + "Usage: /linear ...", + ); } diff --git a/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md b/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md index 9d23fcc7c..7ac3e03ce 100644 --- a/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md +++ b/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md @@ -28,6 +28,41 @@ A small set of domains require the **desktop bridge** because the underlying ser When no desktop is running, calls into a bridge-backed domain surface as `Domain unavailable` or `Desktop browser bridge not running at . Open ADE Desktop with a project to enable \`ade browser\` commands.` — report the blocker and continue with the rest of the control plane, which is unaffected. +## Linear issues attached to your session + +See the **ade-linear** skill for the full read/write workflow on an attached issue; the essentials: + +When ADE launches you with an attached Linear issue, it injects two env vars into your session: `ADE_CHAT_SESSION_ID` (your session) and `ADE_LINEAR_ISSUE_IDS` (comma-separated attached issue ids). You read and write that issue through the **daemon bridge** — `ade linear ...` routes over the daemon to the desktop runtime, which holds the Linear credentials. You never need a Linear token. + +Read/write your attached issue (id defaults to your session's first attached issue, so you can omit it): + +``` +ade linear issues --this-session --text # what is attached to me +ade linear issue --text # read the attached issue +ade linear comment "Pushed a fix; CI running" +ade linear set-state ENG-431 # move workflow state +ade linear assign ENG-431 +ade linear label ENG-431 needs-review +``` + +Manage attachments: + +``` +ade linear attach --this-session --issue-id ENG-431 # attach to my session +ade linear detach --this-session [--issue-id ENG-431] # detach one or all +ade chat attach-linear-issue --issue-id ENG-431 +ade lanes link-linear-issue --linear-issue-json '{...}' +``` + +Start work from an issue: + +``` +ade lanes create-from-linear --issue-id ENG-431 --start-chat --provider codex --model +ade chat create --from-linear-issue ENG-431 # chat with the issue attached + kickoff +``` + +Report what you actually did back to the issue with `ade linear comment` as you progress — that comment is how reviewers and the issue's watchers see status. Use `ade help linear` for the full flag set. + ## Fallback path If `command -v ade` fails: diff --git a/apps/desktop/resources/agent-skills/ade-linear/SKILL.md b/apps/desktop/resources/agent-skills/ade-linear/SKILL.md new file mode 100644 index 000000000..c284f7e21 --- /dev/null +++ b/apps/desktop/resources/agent-skills/ade-linear/SKILL.md @@ -0,0 +1,121 @@ +--- +name: ade-linear +description: Use this skill whenever your task is a Linear issue (or an ADE chat/lane launched with a Linear issue attached) and you need to read or update that issue — change its workflow state, comment progress, assign it, add a label, or read its comments. ADE routes all of this through its own Linear connection via the `ade linear` CLI, so you have effective Linear write access with no API key. +--- + +# ADE Linear + +## What you have + +If ADE launched you on a Linear issue, that issue lives in **ADE's Linear +connection** and you can both **read and write** it through the `ade linear` +CLI. You do not hold a Linear token and you do not need one: every `ade linear` +command routes over the ADE daemon to the desktop runtime, which owns the Linear +credentials. The daemon does the authenticated Linear API call on your behalf. + +You do **not** need to know how you were launched, what mode you are in, or how +the issue got attached. You have a task, an attached issue, and these commands. + +Prefer `ade linear` over any Linear MCP server or direct Linear API call — the +`ade linear` bridge is the connection that is actually configured for this +workspace, and it keeps the issue's ADE links and sync state consistent. + +## Knowing which issue is yours + +When ADE attaches an issue to your session it injects two environment variables: + +- `ADE_LINEAR_ISSUE_IDS` — comma-separated identifiers of every attached issue, + e.g. `ENG-431,ENG-440`. The first one is your primary issue. +- `ADE_LINEAR_CONTEXT_FILE` — absolute path to a JSON file describing the + attached issues. Read it for title, state, URL, and team before you act. +- `ADE_CHAT_SESSION_ID` — your session id (used by the `--this-session` flag). + +The context file looks like: + +```json +{ + "sessionId": "...", + "updatedAt": "2026-05-29T...", + "issues": [ + { + "id": "uuid", + "identifier": "ENG-431", + "title": "Fix Linear deeplink race", + "url": "https://linear.app/...", + "stateName": "Todo", + "role": "primary", + "teamKey": "ENG" + } + ] +} +``` + +If those vars are unset, no issue is attached — fall back to passing an explicit +identifier (e.g. `ENG-431`) to the read/write commands below, or check +`ade linear issues` for what your session has. + +## Reading + +The attached-issue commands default to your session's first attached issue when +you omit the id (precedence: `--issue-id ` flag → leading positional → +`$ADE_LINEAR_ISSUE_IDS`). So inside a tracked session you can drop the id. + +```bash +ade linear issues --text # list issues attached to this session +ade linear issue --text # read your attached issue (full detail) +ade linear issue ENG-431 --text # read a specific issue +ade linear issue-comments --issue-id ENG-431 --text # read an issue's comment thread +``` + +Use `--text` for human-readable output; omit it for JSON when you want to parse. + +## Updating (you have write access) + +These move the issue forward through ADE's Linear connection — no API key. Each +takes an optional leading issue id; omit it to target your attached issue. + +```bash +ade linear set-state ENG-431 # move workflow state (e.g. In Progress, Done) +ade linear comment "Pushed a fix, CI is running" # comment on your attached issue +ade linear comment ENG-431 "Done — see PR #123" # comment on a specific issue +ade linear assign me # assign to the connected user +ade linear assign ENG-431 # assign to a specific user +ade linear assign none # clear the assignee (also: null / unassigned) +ade linear label ENG-431 "needs-review" # add a label by name +``` + +Notes: +- `set-state` takes a **workflow state id**, not a free-text name. To discover + the valid states/ids (and users) for the issue picker, run + `ade linear picker-data --text` (you may need to prefix `--role cto`: + `ade --role cto linear picker-data --text`). The state id can also be passed + via `--state-id `. +- `comment`, `set-state`, `assign`, and `label` all accept the value via a flag + too (`--message/-m`, `--state-id`, `--assignee`, `--label`) if a positional is + ambiguous. + +## Attaching / detaching this session + +```bash +ade linear attach --this-session --issue-id ENG-431 # attach an issue to your session +ade linear detach --this-session --issue-id ENG-431 # detach one issue +ade linear detach --this-session # detach every issue from your session +``` + +## Recommended workflow + +1. When you start real work on the issue, move it to **In Progress** + (`ade linear set-state `), so watchers see it's being worked. +2. As you make progress, **comment** what you did and link the PR + (`ade linear comment "..."`). That comment is how reviewers and the issue's + watchers see status — report what you actually did, not what you intend to do. +3. When you finish, set the **appropriate final state** (e.g. Done / In Review). + Defer the exact final-state policy to the user's workflow — if you're unsure + whether to mark Done vs. In Review, comment your result and ask rather than + guessing. + +## Discovery + +Run `ade help linear` for the full flag set, or `ade linear --help`. If `ade` is +not on PATH, see the **ade-cli-control-plane** skill for the fallback resolution +order (`$ADE_CLI_PATH`, `$ADE_CLI_BIN_DIR/ade`, or `node apps/ade-cli/dist/cli.cjs`). diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 93d206ebe..14560f827 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -59,7 +59,7 @@ import { runGit } from "./services/git/git"; import { createJobEngine } from "./services/jobs/jobEngine"; import { createAiIntegrationService } from "./services/ai/aiIntegrationService"; import { augmentProcessPathWithShellAndKnownCliDirs, setPathEnvValue } from "./services/ai/cliExecutableResolver"; -import { createAgentChatService } from "./services/chat/agentChatService"; +import { createAgentChatService, writeSessionLinearIssueContextFile } from "./services/chat/agentChatService"; import { createGithubService } from "./services/github/githubService"; import { createProjectScaffoldService } from "./services/projects/projectScaffoldService"; import { createFeedbackReporterService } from "./services/feedback/feedbackReporterService"; @@ -95,9 +95,7 @@ import type { SyncProjectSwitchRequestPayload, SyncProjectSwitchResultPayload, } from "../shared/types"; -import type { AutomationTriggerType } from "../shared/types/config"; -import type { AutomationTriggerLinearIssueContext } from "../shared/types/automations"; -import type { LinearIngressEventRecord } from "../shared/types/linearSync"; +import { buildLinearAutomationDispatches } from "./services/automations/linearAutomationDispatch"; import type { IosSimulatorDrawerMode } from "../shared/types/iosSimulator"; import type { AppContext } from "./services/ipc/registerIpc"; import fs from "node:fs"; @@ -154,6 +152,7 @@ import { createLinearCredentialService } from "./services/cto/linearCredentialSe import { buildRendererCspPolicy } from "./rendererCsp"; import { createLinearClient } from "./services/cto/linearClient"; import { createLinearIssueTracker, type LinearIssueTracker } from "./services/cto/linearIssueTracker"; +import { createLinearLiveStatusService, type LinearLiveStatusService } from "./services/cto/linearLiveStatusService"; import { createLinearTemplateService } from "./services/cto/linearTemplateService"; import { createFlowPolicyService } from "./services/cto/flowPolicyService"; import { createLinearWorkflowFileService } from "./services/cto/linearWorkflowFileService"; @@ -348,110 +347,6 @@ function readString(source: Record | null | undefined, key: str return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; } -function readStringArray(source: Record | null | undefined, key: string): string[] | undefined { - const value = source?.[key]; - if (!Array.isArray(value)) return undefined; - const out = value.map((entry) => { - if (typeof entry === "string") return entry.trim(); - if (entry && typeof entry === "object") { - const rec = entry as Record; - const name = typeof rec.name === "string" ? rec.name.trim() : null; - if (name) return name; - } - return ""; - }).filter((entry) => entry.length > 0); - return out.length > 0 ? out : undefined; -} - -function readNested(source: Record | null | undefined, key: string): Record | null { - const value = source?.[key]; - return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : null; -} - -function mapLinearActionToTriggerType( - action: string | null, - data: Record | null, - prevData: Record | null, -): { triggerType: AutomationTriggerType; stateTransition: string | null; previousState: string | undefined } { - const currentState = readString(readNested(data, "state"), "name") ?? readString(data, "stateName"); - const previousState = readString(readNested(prevData, "state"), "name") ?? readString(prevData, "stateName"); - if (action === "create") { - return { triggerType: "linear.issue_created", stateTransition: null, previousState: undefined }; - } - const prevAssignee = readString(prevData, "assigneeId") ?? readString(readNested(prevData, "assignee"), "id"); - const curAssignee = readString(data, "assigneeId") ?? readString(readNested(data, "assignee"), "id"); - if (curAssignee && curAssignee !== prevAssignee) { - return { triggerType: "linear.issue_assigned", stateTransition: null, previousState }; - } - if (currentState && previousState && currentState !== previousState) { - return { - triggerType: "linear.issue_status_changed", - stateTransition: `${previousState}->${currentState}`, - previousState, - }; - } - return { triggerType: "linear.issue_updated", stateTransition: null, previousState }; -} - -function buildLinearAutomationDispatch(event: LinearIngressEventRecord): { - source: "linear-relay"; - eventKey: string; - triggerType: AutomationTriggerType; - eventName?: string | null; - summary?: string | null; - author?: string | null; - labels?: string[]; - rawPayload?: Record | null; - linear?: { issue: AutomationTriggerLinearIssueContext } | null; - project?: string | null; - team?: string | null; - assignee?: string | null; - stateTransition?: string | null; - changedFields?: string[]; -} | null { - if (!event.issueId) return null; - const payload = event.payload ?? null; - const data = readNested(payload, "data"); - const prevData = readNested(payload, "updatedFrom"); - const mapping = mapLinearActionToTriggerType(event.action ?? null, data, prevData); - - const teamName = readString(readNested(data, "team"), "name") ?? readString(data, "teamName"); - const projectName = readString(readNested(data, "project"), "name") ?? readString(data, "projectName"); - const assigneeName = readString(readNested(data, "assignee"), "name") ?? readString(data, "assigneeName"); - const stateName = readString(readNested(data, "state"), "name") ?? readString(data, "stateName"); - const labels = readStringArray(data, "labels") ?? readStringArray(readNested(data, "labels"), "nodes"); - const title = readString(data, "title") ?? undefined; - - const changedFields = prevData ? Object.keys(prevData) : undefined; - - const linearContext: AutomationTriggerLinearIssueContext = { - id: event.issueId, - title, - team: teamName, - project: projectName, - assignee: assigneeName, - state: stateName, - previousState: mapping.previousState, - labels, - }; - - return { - source: "linear-relay", - eventKey: event.eventId, - triggerType: mapping.triggerType, - eventName: event.action, - summary: event.summary, - labels, - rawPayload: payload, - linear: { issue: linearContext }, - project: projectName ?? null, - team: teamName ?? null, - assignee: assigneeName ?? null, - stateTransition: mapping.stateTransition, - changedFields, - }; -} - // The Claude CLI refuses to start if it detects it is inside another Claude Code // session (nested session guard). ADE is a host app, not a nested session, so // strip the marker env var so the SDK can spawn the CLI cleanly. @@ -1865,6 +1760,7 @@ app.whenReady().then(async () => { let gitServiceRef: ReturnType | null = null; let linearIssueTrackerRef: LinearIssueTracker | null = null; + let linearLiveStatusServiceRef: LinearLiveStatusService | null = null; const lastHeadByLaneId = new Map(); @@ -2285,6 +2181,7 @@ app.whenReady().then(async () => { autoRebaseService, rebaseSuggestionService, getLinearIssueTracker: () => linearIssueTrackerRef, + getLinearLiveStatusService: () => linearLiveStatusServiceRef, onHotRefreshChanged: () => { prPollingServiceRef?.poke(); }, @@ -2430,6 +2327,42 @@ app.whenReady().then(async () => { }, ), ); + // Live status round-trip (no-op unless flag is set): a PR that just + // transitioned into the merged state moves its linked Linear issues to + // Done. + const liveStatus = linearLiveStatusServiceRef; + if (liveStatus?.enabled) { + const mergedLaneIds = new Set( + changes + .filter((change) => change.previousState !== "merged" && change.pr.state === "merged" && change.pr.laneId) + .map((change) => change.pr.laneId as string), + ); + for (const laneId of mergedLaneIds) { + try { + const lanes = await laneService.list({ includeArchived: true, includeStatus: false }); + const lane = lanes.find((entry) => entry.id === laneId) ?? null; + if (!lane) continue; + const issues = new Map(); + const addIssue = (issue: { id: string; teamKey?: string | null; stateId?: string | null } | null | undefined): void => { + if (issue?.id) issues.set(issue.id, issue); + }; + addIssue(lane.linearIssue ?? null); + for (const link of lane.linearIssueLinks ?? []) { + if (link.closeOnMerge) addIssue(link.issue); + } + for (const link of laneService.listLinearIssuesForLaneSessions?.({ laneId }) ?? []) { + if (link.closeOnMerge) addIssue(link.issue); + } + if (issues.size === 0) continue; + await liveStatus.onIssueMerged({ issues: Array.from(issues.values()) }); + } catch (error) { + logger.warn("linear.live_status_merge_failed", { + laneId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + } }, }); prPollingServiceRef = prPollingService; @@ -2505,6 +2438,42 @@ app.whenReady().then(async () => { }; }; + // Materialize the per-session Linear issue context for a CLI terminal agent + // (mirrors agentChatService.buildAgentRuntimeEnv for SDK chats) so the agent + // can read its attached issues without Linear creds. Keyed by the terminal's + // chat/session id; returns the env vars pointing at the context file. + const getSessionLinearEnv = ({ + sessionId, + chatSessionId, + }: { + sessionId: string; + chatSessionId: string | null; + }): Record | null => { + const linkKey = (chatSessionId ?? sessionId).trim(); + if (!linkKey) return null; + try { + const links = laneService.listLinearIssuesForSession?.({ chatSessionId: linkKey }) ?? []; + const context = writeSessionLinearIssueContextFile({ + contextDir: resolveAdeLayout(projectRoot).contextDir, + sessionId: linkKey, + links, + now: new Date().toISOString(), + }); + if (!context) return null; + return { + ADE_LINEAR_ISSUE_IDS: context.identifiers, + ADE_LINEAR_CONTEXT_FILE: context.filePath, + }; + } catch (error) { + logger.warn("pty.session_linear_env_failed", { + sessionId, + chatSessionId, + error: error instanceof Error ? error.message : String(error), + }); + return null; + } + }; + const onTrackedSessionEnded = ({ laneId, sessionId, @@ -2560,6 +2529,7 @@ app.whenReady().then(async () => { aiIntegrationService, projectConfigService, getLaneRuntimeEnv, + getSessionLinearEnv, getAdeCliAgentEnv: adeCliService.agentEnv, logger, broadcastData: (ev) => { @@ -2714,6 +2684,12 @@ app.whenReady().then(async () => { client: linearClient, }); linearIssueTrackerRef = linearIssueTracker; + // Live status round-trip (gated OFF unless ADE_LINEAR_LIVE_STATUS_ROUNDTRIP=1). + const linearLiveStatusService = createLinearLiveStatusService({ + getIssueTracker: () => linearIssueTrackerRef, + logger, + }); + linearLiveStatusServiceRef = linearLiveStatusService; const linearTemplateService = createLinearTemplateService({ adeDir: adePaths.adeDir, }); @@ -2796,6 +2772,20 @@ app.whenReady().then(async () => { error: error instanceof Error ? error.message : String(error), }); }); + // Agent launched against a Linear issue → reflect status into Linear + // (no-op unless the live round-trip flag is set). + void linearLiveStatusServiceRef?.onAgentLaunched({ + issue, + branchName: issue.branchName, + laneName: sessionTitle, + }).catch((error) => { + logger.warn("linear.live_status_launch_failed", { + laneId, + sessionId, + issueId: issue.id, + error: error instanceof Error ? error.message : String(error), + }); + }); }, onEvent: (event) => { emitProjectEvent(projectRoot, IPC.agentChatEvent, event); @@ -3486,8 +3476,7 @@ app.whenReady().then(async () => { adeIssueLinkCause: isCreatedIssueEvent ? "linear_issue_created" : "linear_issue_ingress", }); try { - const dispatched = buildLinearAutomationDispatch(event); - if (dispatched) { + for (const dispatched of buildLinearAutomationDispatches(event)) { await automationService.dispatchIngressTrigger(dispatched); } } catch (error) { diff --git a/apps/desktop/src/main/services/adeActions/registry.test.ts b/apps/desktop/src/main/services/adeActions/registry.test.ts index d51ba0056..5efcdfe40 100644 --- a/apps/desktop/src/main/services/adeActions/registry.test.ts +++ b/apps/desktop/src/main/services/adeActions/registry.test.ts @@ -17,6 +17,30 @@ describe("isAllowedAdeAction", () => { expect(isAllowedAdeAction("issue", "addComment")).toBe(true); }); + it("exposes the session-scoped Linear link lane actions for CLI/automation reach", () => { + expect(isAllowedAdeAction("lane", "attachLinearIssueToSession")).toBe(true); + expect(isAllowedAdeAction("lane", "detachLinearIssueFromSession")).toBe(true); + expect(isAllowedAdeAction("lane", "listLinearIssuesForSession")).toBe(true); + expect(isAllowedAdeAction("lane", "listLinearIssuesForLaneSessions")).toBe(true); + expect(isAllowedAdeAction("lane", "unlinkLinearIssues")).toBe(true); + }); + + it("exposes the Linear issue tracker write actions for the CLI daemon bridge", () => { + // CLI agents have no Linear creds; they write back through the daemon + // bridge, so these must be agent-reachable (not CTO-gated). + expect(isAllowedAdeAction("linear_issue_tracker", "updateIssueState")).toBe(true); + expect(isAllowedAdeAction("linear_issue_tracker", "createComment")).toBe(true); + expect(isAllowedAdeAction("linear_issue_tracker", "updateIssueAssignee")).toBe(true); + expect(isAllowedAdeAction("linear_issue_tracker", "addLabel")).toBe(true); + expect(isCtoOnlyAdeAction("linear_issue_tracker", "updateIssueState")).toBe(false); + expect(isCtoOnlyAdeAction("linear_issue_tracker", "addLabel")).toBe(false); + }); + + it("exposes CLI agent launch through the chat runtime action surface", () => { + expect(isAllowedAdeAction("chat", "launchCli")).toBe(true); + expect(isCtoOnlyAdeAction("chat", "launchCli")).toBe(false); + }); + it("rejects an unknown action on a known domain", () => { expect(isAllowedAdeAction("git", "rmRf")).toBe(false); expect(isAllowedAdeAction("issue", "deleteAllIssues")).toBe(false); @@ -416,6 +440,67 @@ describe("runtime Linear OAuth actions", () => { }); describe("runtime session actions", () => { + it("launches tracked CLI agents through runtime chat actions", async () => { + const ptyCreate = vi.fn(async (args: { sessionId: string }) => ({ + sessionId: args.sessionId, + ptyId: "pty-1", + pid: 123, + })); + const runtime = { + projectRoot: "/repo", + agentChatService: {}, + laneService: { + getLaneWorktreePath: vi.fn(() => "/repo/.ade/worktrees/lane-1"), + getLaneBaseAndBranch: vi.fn(), + }, + ptyService: { + create: ptyCreate, + }, + } as unknown as Parameters[0]; + const chatService = getAdeActionDomainServices(runtime).chat as { + launchCli: (args: { + laneId: string; + provider: "codex"; + model: string; + kickoffPrompt: string; + linearIssues: Array<{ id: string }>; + }) => Promise<{ sessionId: string; ptyId: string; pid: number | null; attachedLinearIssueIds: string[] }>; + } & Record; + + expect(listAllowedAdeActionNames("chat", chatService)).toContain("launchCli"); + const result = await chatService.launchCli({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + kickoffPrompt: "Work the issue", + linearIssues: [{ id: "issue-1" }], + }); + + const createArg = ptyCreate.mock.calls[0]?.[0] as { + sessionId: string; + chatSessionId: string; + laneId: string; + tracked: boolean; + toolType: string; + linearIssues: Array<{ id: string }>; + startupCommand: string; + }; + expect(createArg).toMatchObject({ + chatSessionId: createArg.sessionId, + laneId: "lane-1", + tracked: true, + toolType: "codex", + linearIssues: [{ id: "issue-1" }], + }); + expect(createArg.startupCommand).toContain("codex"); + expect(result).toMatchObject({ + sessionId: createArg.sessionId, + ptyId: "pty-1", + pid: 123, + attachedLinearIssueIds: ["issue-1"], + }); + }); + it("adds getDelta from the runtime session delta service", () => { const delta = { sessionId: "session-1", filesChanged: 2 }; const runtime = { diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index e89e82bdd..71a3239e7 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -20,6 +20,8 @@ import type { AgentChatCodexOpenInCliArgs, AgentChatCodexOpenInCliResult, AgentChatGetTurnFileDiffArgs, + AgentChatLaunchCliArgs, + AgentChatLaunchCliResult, AgentChatParallelLaunchState, AgentChatSetParallelLaunchStateArgs, AgentChatTurnFileDiff, @@ -81,6 +83,7 @@ import { detectCodexResumeStrategy, spawnInNewTerminalWindow, } from "../chat/codexCliLauncher"; +import { launchAgentChatCli } from "../chat/agentChatCliLaunch"; import { createApnsBridgeService } from "../notifications/apnsBridgeService"; import { deleteTerminalSessionWithRuntimeCleanup } from "../sessions/deleteTerminalSession"; @@ -188,6 +191,7 @@ export const ADE_ACTION_ALLOWLIST: Partial => + launchAgentChatCli(args, { + laneService: requireService( + runtime.laneService, + "Lane service not available.", + ), + ptyService: requireService( + runtime.ptyService, + "Terminal service not available.", + ), + }), modelCatalog: (args?: unknown) => agentChatService.getModelCatalog(args && typeof args === "object" ? args as never : undefined), codexOpenInCli: async ( diff --git a/apps/desktop/src/main/services/automations/automationService.test.ts b/apps/desktop/src/main/services/automations/automationService.test.ts index ea04f6c86..6d653b246 100644 --- a/apps/desktop/src/main/services/automations/automationService.test.ts +++ b/apps/desktop/src/main/services/automations/automationService.test.ts @@ -6,6 +6,8 @@ import { createRequire } from "node:module"; import initSqlJs from "sql.js"; import type { Database, SqlJsStatic } from "sql.js"; import { createAutomationService, normalizeRuntimeRule, presetToTemplate, triggerMatches } from "./automationService"; +import { buildLinearAutomationDispatches } from "./linearAutomationDispatch"; +import type { LinearIngressEventRecord } from "../../../shared/types/linearSync"; type SqlValue = string | number | null | Uint8Array; @@ -76,6 +78,58 @@ describe("triggerMatches", () => { undefined, )).toBe(false); }); + + it("matches linear.issue_labeled against the added labels only", () => { + // The dispatch carries only the added label names in `labels`; the issue's + // full label set lives in `linear.issue.labels`. + const trigger = { + triggerType: "linear.issue_labeled" as const, + labels: ["ready-for-ade"], + linear: { + issue: { + id: "issue-1", + title: "Fix OAuth", + team: "ENG", + labels: ["bug", "ready-for-ade", "p1"], + }, + }, + }; + + // Configured label is among the added labels → matches. + expect(triggerMatches( + { type: "linear.issue_labeled", labels: ["ready-for-ade"] }, + trigger, + undefined, + undefined, + )).toBe(true); + + // A label that's on the issue but was NOT just added must not match. + expect(triggerMatches( + { type: "linear.issue_labeled", labels: ["p1"] }, + trigger, + undefined, + undefined, + )).toBe(false); + }); + + it("requires at least one added label for a label rule with no configured label", () => { + const base = { + triggerType: "linear.issue_labeled" as const, + linear: { issue: { id: "issue-2", title: "X", team: "ENG", labels: ["x"] } }, + }; + expect(triggerMatches( + { type: "linear.issue_labeled" }, + { ...base, labels: ["x"] }, + undefined, + undefined, + )).toBe(true); + expect(triggerMatches( + { type: "linear.issue_labeled" }, + { ...base, labels: [] }, + undefined, + undefined, + )).toBe(false); + }); }); describe("normalizeRuntimeRule", () => { @@ -1499,3 +1553,144 @@ describe("automationService integration", () => { }); }); + +// Folded from the former linearAutomationDispatch.test.ts: the Linear webhook → +// automation-trigger mapping (label-add one-shot diffing) is part of the same +// automation-trigger contract, so it lives here rather than in its own file. +function makeLinearEvent(overrides: Partial = {}): LinearIngressEventRecord { + return { + id: "row-1", + source: "relay", + deliveryId: "delivery-1", + kind: "issue.update", + eventId: "evt-1", + entityType: "Issue", + action: "update", + issueId: "issue-1", + issueIdentifier: "ENG-1", + summary: "ENG-1: Fix OAuth", + payload: null, + createdAt: "2026-05-29T00:00:00.000Z", + ...overrides, + }; +} + +describe("buildLinearAutomationDispatches", () => { + it("returns nothing for events without an issue id", () => { + expect(buildLinearAutomationDispatches(makeLinearEvent({ issueId: null }))).toEqual([]); + }); + + it("emits issue_updated for a plain edit with no label change", () => { + const event = makeLinearEvent({ + payload: { + data: { title: "Fix OAuth", labelIds: ["l1"], labels: [{ id: "l1", name: "bug" }] }, + updatedFrom: { title: "Old" }, + }, + }); + const dispatches = buildLinearAutomationDispatches(event); + expect(dispatches.map((d) => d.triggerType)).toEqual(["linear.issue_updated"]); + }); + + it("emits a one-shot issue_labeled and suppresses issue_updated for a pure label add", () => { + const event = makeLinearEvent({ + payload: { + data: { + labelIds: ["l1", "l2"], + labels: [ + { id: "l1", name: "bug" }, + { id: "l2", name: "ready-for-ade" }, + ], + }, + updatedFrom: { labelIds: ["l1"] }, + }, + }); + const dispatches = buildLinearAutomationDispatches(event); + // Only the labeled event fires — no duplicate issue_updated. + expect(dispatches.map((d) => d.triggerType)).toEqual(["linear.issue_labeled"]); + const labeled = dispatches[0]!; + // The matchable labels are the *added* names only. + expect(labeled.labels).toEqual(["ready-for-ade"]); + expect(labeled.eventKey).toContain("labeled"); + expect((labeled.rawPayload as { addedLabels?: string[] })?.addedLabels).toEqual(["ready-for-ade"]); + }); + + it("keeps a concurrent status change alongside the labeled event", () => { + const event = makeLinearEvent({ + payload: { + data: { + state: { name: "In Progress" }, + labelIds: ["l1", "l2"], + labels: [ + { id: "l1", name: "bug" }, + { id: "l2", name: "ready-for-ade" }, + ], + }, + updatedFrom: { labelIds: ["l1"], state: { name: "Todo" } }, + }, + }); + const dispatches = buildLinearAutomationDispatches(event); + // Both fire: the labeled one-shot and the real status transition. + expect(dispatches.map((d) => d.triggerType).sort()).toEqual([ + "linear.issue_labeled", + "linear.issue_status_changed", + ]); + const status = dispatches.find((d) => d.triggerType === "linear.issue_status_changed")!; + expect(status.stateTransition).toBe("Todo->In Progress"); + }); + + it("keeps a concurrent assignment alongside the labeled event", () => { + const event = makeLinearEvent({ + payload: { + data: { + assigneeId: "user-2", + labelIds: ["l1", "l2"], + labels: [ + { id: "l1", name: "bug" }, + { id: "l2", name: "ready-for-ade" }, + ], + }, + updatedFrom: { labelIds: ["l1"], assigneeId: "user-1" }, + }, + }); + const dispatches = buildLinearAutomationDispatches(event); + expect(dispatches.map((d) => d.triggerType).sort()).toEqual([ + "linear.issue_assigned", + "linear.issue_labeled", + ]); + }); + + it("does not treat a removed label as an add", () => { + const event = makeLinearEvent({ + payload: { + data: { labelIds: ["l1"], labels: [{ id: "l1", name: "bug" }] }, + updatedFrom: { labelIds: ["l1", "l2"] }, + }, + }); + const dispatches = buildLinearAutomationDispatches(event); + expect(dispatches.map((d) => d.triggerType)).toEqual(["linear.issue_updated"]); + }); + + it("does not emit issue_labeled on create even when labels are present", () => { + const event = makeLinearEvent({ + action: "create", + payload: { + data: { labelIds: ["l1"], labels: [{ id: "l1", name: "bug" }] }, + }, + }); + const dispatches = buildLinearAutomationDispatches(event); + expect(dispatches.map((d) => d.triggerType)).toEqual(["linear.issue_created"]); + }); + + it("ignores an added label id with no resolvable name", () => { + const event = makeLinearEvent({ + payload: { + // l2 was added but has no entry in `labels`, so we can't name it → skip. + data: { labelIds: ["l1", "l2"], labels: [{ id: "l1", name: "bug" }] }, + updatedFrom: { labelIds: ["l1"] }, + }, + }); + const dispatches = buildLinearAutomationDispatches(event); + // No nameable added label → fall through to the plain update event. + expect(dispatches.map((d) => d.triggerType)).toEqual(["linear.issue_updated"]); + }); +}); diff --git a/apps/desktop/src/main/services/automations/automationService.ts b/apps/desktop/src/main/services/automations/automationService.ts index 27c9c9df0..01e53c45a 100644 --- a/apps/desktop/src/main/services/automations/automationService.ts +++ b/apps/desktop/src/main/services/automations/automationService.ts @@ -393,10 +393,19 @@ export function triggerMatches( if (!triggerAuthor || !expectedAuthors.includes(triggerAuthor)) return false; } - const eventLabels = normalizeSet(trigger.issue?.labels ?? trigger.pr?.labels ?? trigger.labels ?? []); + // For `linear.issue_labeled` the dispatch carries only the *added* label names + // in `trigger.labels`, so the label filter matches the rule's configured label + // against what was just added (one-shot on add), not the issue's full label set. + const eventLabels = canonicalType === "linear.issue_labeled" + ? normalizeSet(trigger.labels ?? []) + : normalizeSet(trigger.issue?.labels ?? trigger.pr?.labels ?? trigger.labels ?? []); if (ruleTrigger.labels?.length) { const expected = ruleTrigger.labels.map((l) => l.trim().toLowerCase()).filter(Boolean); if (!expected.every((l) => eventLabels.has(l))) return false; + } else if (canonicalType === "linear.issue_labeled" && eventLabels.size === 0) { + // A labeled rule with no configured label still requires at least one added + // label to have triggered the event. + return false; } if (ruleTrigger.paths?.length) { diff --git a/apps/desktop/src/main/services/automations/linearAutomationDispatch.ts b/apps/desktop/src/main/services/automations/linearAutomationDispatch.ts new file mode 100644 index 000000000..38cb86a62 --- /dev/null +++ b/apps/desktop/src/main/services/automations/linearAutomationDispatch.ts @@ -0,0 +1,207 @@ +import type { AutomationTriggerType } from "../../../shared/types/config"; +import type { AutomationTriggerLinearIssueContext } from "../../../shared/types/automations"; +import type { LinearIngressEventRecord } from "../../../shared/types/linearSync"; + +/** + * One ingress dispatch derived from a Linear relay event. Mirrors the subset of + * `automationService.dispatchIngressTrigger` args that the Linear path populates. + * A single relay event can produce more than one of these (e.g. a label add that + * also touches state emits both `linear.issue_labeled` and the state change). + */ +export type LinearAutomationDispatch = { + source: "linear-relay"; + eventKey: string; + triggerType: AutomationTriggerType; + eventName?: string | null; + summary?: string | null; + author?: string | null; + labels?: string[]; + rawPayload?: Record | null; + linear?: { issue: AutomationTriggerLinearIssueContext } | null; + project?: string | null; + team?: string | null; + assignee?: string | null; + stateTransition?: string | null; + changedFields?: string[]; +}; + +function readString(source: Record | null | undefined, key: string): string | undefined { + const value = source?.[key]; + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +function readStringArray(source: Record | null | undefined, key: string): string[] | undefined { + const value = source?.[key]; + if (!Array.isArray(value)) return undefined; + const out = value.map((entry) => { + if (typeof entry === "string") return entry.trim(); + if (entry && typeof entry === "object") { + const rec = entry as Record; + const name = typeof rec.name === "string" ? rec.name.trim() : null; + if (name) return name; + } + return ""; + }).filter((entry) => entry.length > 0); + return out.length > 0 ? out : undefined; +} + +function readNested(source: Record | null | undefined, key: string): Record | null { + const value = source?.[key]; + return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : null; +} + +/** Raw label ids on a Linear payload node (`data` or `updatedFrom`). */ +function readLabelIds(source: Record | null | undefined): string[] { + const value = source?.labelIds; + if (!Array.isArray(value)) return []; + return value.filter((entry): entry is string => typeof entry === "string"); +} + +/** + * Build a name lookup from a Linear payload's `labels` node (`[{ id, name }]`). + * Lets us translate added label ids into the human-readable names rules match on. + */ +function buildLabelNameById(data: Record | null): Map { + const map = new Map(); + const raw = data?.labels; + const nodes = Array.isArray(raw) + ? raw + : Array.isArray((raw as Record | null)?.nodes) + ? ((raw as Record).nodes as unknown[]) + : []; + for (const entry of nodes) { + if (!entry || typeof entry !== "object") continue; + const rec = entry as Record; + const id = typeof rec.id === "string" ? rec.id : null; + const name = typeof rec.name === "string" ? rec.name.trim() : null; + if (id && name) map.set(id, name); + } + return map; +} + +function mapLinearActionToTriggerType( + action: string | null, + data: Record | null, + prevData: Record | null, +): { triggerType: AutomationTriggerType; stateTransition: string | null; previousState: string | undefined } { + const currentState = readString(readNested(data, "state"), "name") ?? readString(data, "stateName"); + const previousState = readString(readNested(prevData, "state"), "name") ?? readString(prevData, "stateName"); + if (action === "create") { + return { triggerType: "linear.issue_created", stateTransition: null, previousState: undefined }; + } + const prevAssignee = readString(prevData, "assigneeId") ?? readString(readNested(prevData, "assignee"), "id"); + const curAssignee = readString(data, "assigneeId") ?? readString(readNested(data, "assignee"), "id"); + if (curAssignee && curAssignee !== prevAssignee) { + return { triggerType: "linear.issue_assigned", stateTransition: null, previousState }; + } + if (currentState && previousState && currentState !== previousState) { + return { + triggerType: "linear.issue_status_changed", + stateTransition: `${previousState}->${currentState}`, + previousState, + }; + } + return { triggerType: "linear.issue_updated", stateTransition: null, previousState }; +} + +/** + * Translate a Linear relay event into the automation ingress dispatches it + * implies. Returns an empty array for events without a resolvable issue. + * + * Label semantics (mirrors `githubPollingService` `github.issue_labeled`): when + * a webhook reveals newly *added* labels (current `labelIds` minus + * `updatedFrom.labelIds`), we emit a one-shot `linear.issue_labeled` whose + * matchable `labels` are the added names only. To avoid a single label add + * counting twice we suppress the generic `linear.issue_updated` fallthrough; an + * assignment or status change that happened in the same payload still emits its + * own event alongside the labeled one. + */ +export function buildLinearAutomationDispatches(event: LinearIngressEventRecord): LinearAutomationDispatch[] { + if (!event.issueId) return []; + const payload = event.payload ?? null; + const data = readNested(payload, "data"); + const prevData = readNested(payload, "updatedFrom"); + const mapping = mapLinearActionToTriggerType(event.action ?? null, data, prevData); + + const teamName = readString(readNested(data, "team"), "name") ?? readString(data, "teamName"); + const projectName = readString(readNested(data, "project"), "name") ?? readString(data, "projectName"); + const assigneeName = readString(readNested(data, "assignee"), "name") ?? readString(data, "assigneeName"); + const stateName = readString(readNested(data, "state"), "name") ?? readString(data, "stateName"); + const labels = readStringArray(data, "labels") ?? readStringArray(readNested(data, "labels"), "nodes"); + const title = readString(data, "title") ?? undefined; + + const changedFields = prevData ? Object.keys(prevData) : undefined; + + const linearContext: AutomationTriggerLinearIssueContext = { + id: event.issueId, + title, + team: teamName, + project: projectName, + assignee: assigneeName, + state: stateName, + previousState: mapping.previousState, + labels, + }; + + // Resolve newly added labels by diffing label ids; only meaningful on updates + // (a create carries no `updatedFrom`). Linear only includes `labelIds` in + // `updatedFrom` when the label set was part of the change, so its absence + // means the labels did not change in this event — never diff against an empty + // prev set, or every edit would look like it added all labels. + const addedLabelNames: string[] = (() => { + if (event.action === "create" || !prevData) return []; + if (!Array.isArray(prevData.labelIds)) return []; + const prevIds = new Set(readLabelIds(prevData)); + const currentIds = readLabelIds(data); + const addedIds = currentIds.filter((id) => !prevIds.has(id)); + if (!addedIds.length) return []; + const nameById = buildLabelNameById(data); + return addedIds + .map((id) => nameById.get(id)) + .filter((name): name is string => typeof name === "string" && name.length > 0); + })(); + + const dispatches: LinearAutomationDispatch[] = []; + + if (addedLabelNames.length) { + dispatches.push({ + source: "linear-relay", + eventKey: `${event.eventId}:labeled:${addedLabelNames.join(",")}`, + triggerType: "linear.issue_labeled", + eventName: event.action, + summary: event.summary, + // The matchable label set is the *added* names so rules fire once, on add. + labels: addedLabelNames, + rawPayload: { ...(payload ?? {}), addedLabels: addedLabelNames }, + linear: { issue: linearContext }, + project: projectName ?? null, + team: teamName ?? null, + assignee: assigneeName ?? null, + stateTransition: null, + changedFields, + }); + } + + // Dedup: a pure label add (mapping fell through to issue_updated) must not also + // fire issue_updated. A concurrent assignment/status change keeps its own event. + const suppressBaseEvent = addedLabelNames.length > 0 && mapping.triggerType === "linear.issue_updated"; + if (!suppressBaseEvent) { + dispatches.push({ + source: "linear-relay", + eventKey: event.eventId, + triggerType: mapping.triggerType, + eventName: event.action, + summary: event.summary, + labels, + rawPayload: payload, + linear: { issue: linearContext }, + project: projectName ?? null, + team: teamName ?? null, + assignee: assigneeName ?? null, + stateTransition: mapping.stateTransition, + changedFields, + }); + } + + return dispatches; +} diff --git a/apps/desktop/src/main/services/chat/agentChatCliLaunch.test.ts b/apps/desktop/src/main/services/chat/agentChatCliLaunch.test.ts new file mode 100644 index 000000000..9b4f9d2e1 --- /dev/null +++ b/apps/desktop/src/main/services/chat/agentChatCliLaunch.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it, vi } from "vitest"; +import type { AgentChatLaunchCliArgs } from "../../../shared/types/chat"; +import type { PtyCreateArgs } from "../../../shared/types"; +import { launchAgentChatCli, type AgentChatCliLaunchDeps } from "./agentChatCliLaunch"; + +type LaneBaseAndBranch = + AgentChatCliLaunchDeps["laneService"]["getLaneBaseAndBranch"] extends ( + laneId: string, + ) => infer R + ? R + : never; + +/** + * Builds deps whose pty `create` echoes back a deterministic session result. + * `getLaneWorktreePath` resolves by default; tests override it to exercise the + * worktree-path resolution fallbacks. The lane-row mock only needs to surface a + * `worktreePath`; the launch path ignores the other lane-row fields, so a + * partial row is cast to the full return shape rather than fabricating dummies. + */ +function makeDeps( + overrides: { + getLaneWorktreePath?: () => string; + getLaneBaseAndBranch?: () => Partial | undefined; + } = {}, +): AgentChatCliLaunchDeps & { create: ReturnType } { + const create = vi.fn(async (args: PtyCreateArgs) => ({ + sessionId: args.sessionId ?? "session-1", + ptyId: "pty-1", + pid: 4242, + })); + const getLaneBaseAndBranch = (overrides.getLaneBaseAndBranch ?? + vi.fn(() => undefined)) as () => LaneBaseAndBranch; + return { + laneService: { + getLaneWorktreePath: + overrides.getLaneWorktreePath ?? vi.fn(() => "/repo/.ade/worktrees/lane-1"), + getLaneBaseAndBranch, + }, + ptyService: { create }, + create, + }; +} + +function makeArgs(overrides: Partial = {}): AgentChatLaunchCliArgs { + return { + laneId: "lane-1", + provider: "codex", + kickoffPrompt: "Resolve the attached issue", + ...overrides, + }; +} + +describe("launchAgentChatCli provider validation", () => { + it("rejects an unknown provider at the runtime boundary", async () => { + const deps = makeDeps(); + await expect( + launchAgentChatCli( + makeArgs({ provider: "gemini" as AgentChatLaunchCliArgs["provider"] }), + deps, + ), + ).rejects.toThrow("agentChat.launchCli: unsupported provider 'gemini'."); + // The guard must fire before any process spawn. + expect(deps.create).not.toHaveBeenCalled(); + }); + + it("rejects the 'shell' launch profile (a profile, not an agent provider)", async () => { + // "shell" passes isLaunchProfile but is not an agent provider, so it must be + // rejected too — otherwise a shell session would spawn with an agent toolType. + const deps = makeDeps(); + await expect( + launchAgentChatCli( + makeArgs({ provider: "shell" as AgentChatLaunchCliArgs["provider"] }), + deps, + ), + ).rejects.toThrow("agentChat.launchCli: unsupported provider 'shell'."); + expect(deps.create).not.toHaveBeenCalled(); + }); + + it("requires a laneId, a provider, and a non-blank kickoff prompt", async () => { + const deps = makeDeps(); + await expect( + launchAgentChatCli(makeArgs({ laneId: " " }), deps), + ).rejects.toThrow("requires a laneId"); + await expect( + launchAgentChatCli( + makeArgs({ provider: undefined as unknown as AgentChatLaunchCliArgs["provider"] }), + deps, + ), + ).rejects.toThrow("requires a provider"); + await expect( + launchAgentChatCli(makeArgs({ kickoffPrompt: " " }), deps), + ).rejects.toThrow("requires a kickoff prompt"); + expect(deps.create).not.toHaveBeenCalled(); + }); +}); + +describe("launchAgentChatCli worktree-path resolution", () => { + it("falls back to the lane row snapshot when getLaneWorktreePath throws", async () => { + const getLaneWorktreePath = vi.fn(() => { + throw new Error("lane not registered in-process"); + }); + const getLaneBaseAndBranch = vi.fn(() => ({ worktreePath: "/imported/lane/path" })); + const deps = makeDeps({ getLaneWorktreePath, getLaneBaseAndBranch }); + + const result = await launchAgentChatCli(makeArgs(), deps); + + // The throw was swallowed and the row-snapshot fallback supplied the path, + // so the launch proceeded instead of erroring out. + expect(getLaneBaseAndBranch).toHaveBeenCalledWith("lane-1"); + expect(deps.create).toHaveBeenCalledTimes(1); + expect(result.sessionId).toBe( + (deps.create.mock.calls[0]?.[0] as { sessionId: string }).sessionId, + ); + }); + + it("throws a clear error when neither source yields a worktree path", async () => { + const deps = makeDeps({ + getLaneWorktreePath: vi.fn(() => " "), + getLaneBaseAndBranch: vi.fn(() => undefined), + }); + await expect(launchAgentChatCli(makeArgs(), deps)).rejects.toThrow( + "Unable to resolve worktree path for lane 'lane-1'.", + ); + expect(deps.create).not.toHaveBeenCalled(); + }); +}); + +describe("launchAgentChatCli attached issue ids", () => { + it("returns only well-formed attached issue ids and drops malformed entries", async () => { + const deps = makeDeps(); + const result = await launchAgentChatCli( + makeArgs({ + linearIssues: [ + { id: "issue-good" } as never, + { id: "" } as never, + { id: null } as never, + {} as never, + ], + }), + deps, + ); + + expect(result.attachedLinearIssueIds).toEqual(["issue-good"]); + expect(result).toMatchObject({ ptyId: "pty-1", pid: 4242 }); + // The full issue list (including malformed shapes) is still forwarded to the + // pty so persistence can decide; only the returned id summary is filtered. + const createArg = deps.create.mock.calls[0]?.[0] as { linearIssues: unknown[] }; + expect(createArg.linearIssues).toHaveLength(4); + }); +}); diff --git a/apps/desktop/src/main/services/chat/agentChatCliLaunch.ts b/apps/desktop/src/main/services/chat/agentChatCliLaunch.ts new file mode 100644 index 000000000..9a4ce5fb1 --- /dev/null +++ b/apps/desktop/src/main/services/chat/agentChatCliLaunch.ts @@ -0,0 +1,137 @@ +import { randomUUID } from "node:crypto"; +import { + buildTrackedCliLaunchCommand, + isLaunchProfile, + LAUNCH_PROFILE_TITLE, + LAUNCH_PROFILE_TOOL_TYPE, +} from "../../../shared/cliLaunch"; +import type { + AgentChatLaunchCliArgs, + AgentChatLaunchCliResult, +} from "../../../shared/types/chat"; +import type { createLaneService } from "../lanes/laneService"; +import type { createPtyService } from "../pty/ptyService"; + +type LaneServiceForCliLaunch = Pick< + ReturnType, + "getLaneWorktreePath" | "getLaneBaseAndBranch" +>; + +type PtyServiceForCliLaunch = Pick, "create">; + +type LoggerForCliLaunch = { + info: (message: string, meta?: Record) => void; +}; + +export type AgentChatCliLaunchDeps = { + laneService: LaneServiceForCliLaunch; + ptyService: PtyServiceForCliLaunch; + logger?: LoggerForCliLaunch | null; +}; + +function resolveLaneWorktreePath( + laneService: LaneServiceForCliLaunch, + laneId: string, +): string { + try { + const worktreePath = laneService.getLaneWorktreePath(laneId); + const trimmed = typeof worktreePath === "string" ? worktreePath.trim() : ""; + if (trimmed) return trimmed; + } catch { + // Fall through to the row snapshot below for imported/attached lane shapes. + } + + try { + const lane = laneService.getLaneBaseAndBranch(laneId); + const trimmed = + typeof lane?.worktreePath === "string" ? lane.worktreePath.trim() : ""; + if (trimmed) return trimmed; + } catch { + // Surface one clear error below. + } + + throw new Error(`Unable to resolve worktree path for lane '${laneId}'.`); +} + +export async function launchAgentChatCli( + arg: AgentChatLaunchCliArgs, + deps: AgentChatCliLaunchDeps, +): Promise { + const laneId = typeof arg?.laneId === "string" ? arg.laneId.trim() : ""; + if (!laneId) throw new Error("agentChat.launchCli requires a laneId."); + const provider = arg?.provider; + if (!provider) throw new Error("agentChat.launchCli requires a provider."); + // Validate at the runtime boundary: the renderer always passes a known CLI + // provider, but the run_ade_action / IPC surfaces accept raw args. An unknown + // provider would otherwise fall through to an OpenCode launch with an + // undefined tool type/title (a silent misclassification or a cryptic DB error). + // "shell" is a launch profile but not an agent provider, so reject it too. + const providerKey = String(provider); + if (!isLaunchProfile(providerKey) || providerKey === "shell") { + throw new Error(`agentChat.launchCli: unsupported provider '${providerKey}'.`); + } + const kickoffPrompt = + typeof arg?.kickoffPrompt === "string" ? arg.kickoffPrompt : ""; + if (!kickoffPrompt.trim().length) { + throw new Error("agentChat.launchCli requires a kickoff prompt."); + } + + const worktreePath = resolveLaneWorktreePath(deps.laneService, laneId); + + // The terminal session id is the Linear link key. For Claude, reuse it as + // the CLI `--session-id` so resume stays consistent. + const sessionId = randomUUID(); + const permissionMode = arg.permissionMode ?? "full-auto"; + + const issues = Array.isArray(arg.linearIssues) ? arg.linearIssues : []; + const attachedLinearIssueIds = issues + .map((issue) => issue?.id) + .filter((id): id is string => typeof id === "string" && id.length > 0); + + const launch = buildTrackedCliLaunchCommand({ + provider, + permissionMode, + ...(provider === "claude" ? { sessionId } : {}), + model: arg.model ?? null, + reasoningEffort: arg.reasoningEffort ?? null, + initialPrompt: kickoffPrompt, + laneWorktreePath: worktreePath, + }); + + const result = await deps.ptyService.create({ + sessionId, + allowNewSessionId: true, + chatSessionId: sessionId, + laneId, + cols: 100, + rows: 30, + title: arg.title?.trim() || LAUNCH_PROFILE_TITLE[provider], + tracked: true, + toolType: LAUNCH_PROFILE_TOOL_TYPE[provider], + startupCommand: launch.startupCommand, + ...(issues.length ? { linearIssues: issues } : {}), + ...(launch.command !== undefined ? { command: launch.command } : {}), + ...(launch.args !== undefined ? { args: launch.args } : {}), + ...(launch.initialInput !== undefined + ? { initialInput: launch.initialInput } + : {}), + ...(launch.initialInputDelayMs !== undefined + ? { initialInputDelayMs: launch.initialInputDelayMs } + : {}), + ...(launch.env ? { env: launch.env } : {}), + }); + + deps.logger?.info("agentChat.launchCli.created", { + laneId, + sessionId: result.sessionId, + provider, + attachedLinearIssueCount: attachedLinearIssueIds.length, + }); + + return { + sessionId: result.sessionId, + ptyId: result.ptyId, + pid: result.pid, + attachedLinearIssueIds, + }; +} diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 66d495e11..8772aa981 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -39,6 +39,7 @@ vi.mock("@cursor/sdk", () => ({ // --------------------------------------------------------------------------- const mockState = vi.hoisted(() => ({ sessions: new Map(), + sessionLinearLinks: new Map(), uuidCounter: 0, mcpServerCounter: 0, codexThreadCounter: 0, @@ -741,6 +742,8 @@ vi.mock("./droidSdkPool", () => ({ import { buildOpenCodeStreamMessages, buildComputerUseDirective, + buildLinearSessionDirective, + writeSessionLinearIssueContextFile, createAgentChatService, } from "./agentChatService"; import { spawn } from "node:child_process"; @@ -1084,6 +1087,25 @@ function createMockLaneService() { return lane; }), getLane: vi.fn((laneId: string) => lanes.find((lane) => lane.id === laneId) ?? null), + // Session-scoped Linear link store so tests can assert that a launched chat + // actually persists its attached issue (FIX 1) and that the directive + // injection (FIX 4) sees the attached issues. + attachLinearIssueToSession: vi.fn((args: { chatSessionId: string; issues: LaneLinearIssue[]; role?: string }) => { + const existing = mockState.sessionLinearLinks.get(args.chatSessionId) ?? []; + const links = args.issues.map((issue) => ({ + issue, + role: args.role ?? "worked", + source: "chat_attach" as const, + includeInPr: true, + closeOnMerge: false, + evidence: { chatSessionId: args.chatSessionId }, + })); + mockState.sessionLinearLinks.set(args.chatSessionId, [...existing, ...links]); + return links; + }), + linkLinearIssues: vi.fn(() => {}), + listLinearIssuesForSession: vi.fn((args: { chatSessionId: string }) => + mockState.sessionLinearLinks.get(args.chatSessionId) ?? []), } as any; } @@ -1437,6 +1459,7 @@ beforeEach(() => { // home dir into tests, while project-local .claude roots remain distinct. vi.spyOn(os, "homedir").mockReturnValue(tmpHomeRoot); mockState.sessions.clear(); + mockState.sessionLinearLinks.clear(); mockState.uuidCounter = 0; mockState.mcpServerCounter = 0; mockState.codexThreadCounter = 0; @@ -1593,6 +1616,110 @@ describe("buildComputerUseDirective", () => { }); }); +describe("writeSessionLinearIssueContextFile", () => { + function makeSessionLink(overrides: Record = {}) { + return { + id: "link-1", + sessionId: "sess-1", + laneId: null, + role: "worked", + source: "chat_attach", + includeInPr: true, + closeOnMerge: false, + evidence: null, + createdAt: "2026-05-20T10:00:00.000Z", + updatedAt: "2026-05-20T10:00:00.000Z", + issue: { + id: "issue-1", + identifier: "ENG-431", + title: "Fix OAuth refresh", + url: "https://linear.app/acme/issue/ENG-431", + stateName: "In Progress", + teamKey: "ENG", + }, + ...overrides, + } as any; + } + + let contextRoot: string; + beforeEach(() => { + contextRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-chat-linear-context-")); + }); + + it("writes a context file and returns env-ready ids when links exist", () => { + const result = writeSessionLinearIssueContextFile({ + contextDir: contextRoot, + sessionId: "sess-1", + links: [ + makeSessionLink(), + makeSessionLink({ id: "link-2", issue: { ...makeSessionLink().issue, id: "issue-2", identifier: "ENG-440" } }), + ], + now: "2026-05-20T11:00:00.000Z", + }); + + expect(result).not.toBeNull(); + expect(result!.identifiers).toBe("ENG-431,ENG-440"); + expect(result!.issueIds).toBe("issue-1,issue-2"); + expect(result!.filePath).toBe(path.join(contextRoot, "sess-1", "linear-issues.json")); + + const written = JSON.parse(fs.readFileSync(result!.filePath, "utf8")); + expect(written.sessionId).toBe("sess-1"); + expect(written.updatedAt).toBe("2026-05-20T11:00:00.000Z"); + expect(written.issues).toHaveLength(2); + expect(written.issues[0]).toEqual(expect.objectContaining({ + id: "issue-1", + identifier: "ENG-431", + role: "worked", + teamKey: "ENG", + })); + }); + + it("returns null and removes a stale file when there are no links", () => { + const filePath = path.join(contextRoot, "sess-1", "linear-issues.json"); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, "{\"stale\":true}"); + + const result = writeSessionLinearIssueContextFile({ + contextDir: contextRoot, + sessionId: "sess-1", + links: [], + now: "2026-05-20T11:00:00.000Z", + }); + + expect(result).toBeNull(); + expect(fs.existsSync(filePath)).toBe(false); + }); +}); + +describe("buildLinearSessionDirective", () => { + function makeLink(identifier: string) { + return { issue: { identifier } } as any; + } + + it("returns null when there are no attached issues", () => { + expect(buildLinearSessionDirective([])).toBeNull(); + }); + + it("returns null when no link carries a usable identifier", () => { + expect(buildLinearSessionDirective([{ issue: { identifier: "" } } as any])).toBeNull(); + }); + + it("steers the agent to `ade linear` over MCP and lists the deduped identifiers", () => { + const directive = buildLinearSessionDirective([ + makeLink("ENG-12"), + makeLink("ENG-34"), + makeLink("ENG-12"), + ]); + expect(directive).toContain("Linear-tracked work"); + expect(directive).toContain("ENG-12, ENG-34"); + // Deduped — the repeated identifier appears once. + expect(directive?.match(/ENG-12/g)).toHaveLength(1); + expect(directive).toContain("ade linear"); + expect(directive).toContain("Prefer `ade linear`"); + expect(directive).toContain("ade-linear"); + }); +}); + // ============================================================================ // createAgentChatService factory // ============================================================================ @@ -1830,6 +1957,121 @@ describe("createAgentChatService", () => { expect(opts?.systemPrompt?.append).toContain("clean up old, stale, or finished processes"); }); + it("rebuilds the Claude query with the per-turn reasoning effort, not the stale warm-query effort (FIX 3)", async () => { + // Regression: the session pre-warmed a query baked with the create-time + // effort (medium). A later turn requesting xhigh updated the session field + // but ensureClaudeQuery reused the stale warm query, so Claude ran medium. + const send = vi.fn().mockResolvedValue(undefined); + const makeSession = (sdkSessionId: string) => ({ + send, + stream: vi.fn(() => (async function* () { + yield { + type: "result", + subtype: "success", + is_error: false, + session_id: sdkSessionId, + usage: { input_tokens: 1, output_tokens: 1 }, + }; + })()), + close: vi.fn(), + sessionId: sdkSessionId, + query: { + setPermissionMode: vi.fn(async () => undefined), + supportedCommands: vi.fn(async () => []), + }, + }); + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue(makeSession("sdk-effort") as any); + + const { service } = createService(); + // opus supports the xhigh tier; sonnet does not, which would clamp the + // requested effort and mask the regression. + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "opus", + reasoningEffort: "medium", + }); + + // The pre-warm built a query with the create-time (medium) effort. + await vi.waitFor(() => { + expect(claudeSdkCreateSessionCompat).toHaveBeenCalled(); + }); + const warmOpts = vi.mocked(claudeSdkCreateSessionCompat).mock.calls[0]?.[0] as { effort?: string } | undefined; + expect(warmOpts?.effort).toBe("medium"); + + await service.runSessionTurn({ + sessionId: session.id, + text: "Do the deep work.", + reasoningEffort: "xhigh", + timeoutMs: 15_000, + }); + + // The stale warm query was discarded and a fresh query built with xhigh. + const efforts = vi.mocked(claudeSdkCreateSessionCompat).mock.calls + .map((call) => (call[0] as { effort?: string } | undefined)?.effort) + .filter((value): value is string => typeof value === "string"); + expect(efforts).toContain("xhigh"); + expect(session.id).toBeDefined(); + }); + + it("injects the ade-linear directive into the Claude system prompt when the session has attached issues (FIX 4)", async () => { + const send = vi.fn().mockResolvedValue(undefined); + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send, + stream: vi.fn(() => (async function* () { + yield { + type: "result", + subtype: "success", + is_error: false, + session_id: "sdk-linear-directive", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + })()), + close: vi.fn(), + sessionId: "sdk-linear-directive", + query: { + setPermissionMode: vi.fn(async () => undefined), + supportedCommands: vi.fn(async () => []), + }, + } as any); + + const { service, laneService } = createService(); + const issue = makeLaneLinearIssue(); + // opus supports xhigh, so the per-turn effort bump below invalidates the + // create-time warm query (built before the attach) and forces a fresh + // query build that now resolves the attached issue into the directive. + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "opus", + reasoningEffort: "medium", + }); + // Let the create-time warm query settle (built before the attach, so with + // no directive) — the turn must then invalidate and rebuild it. + await vi.waitFor(() => { + expect(claudeSdkCreateSessionCompat).toHaveBeenCalledTimes(1); + }); + laneService.attachLinearIssueToSession({ + chatSessionId: session.id, + issues: [issue], + }); + + await service.runSessionTurn({ + sessionId: session.id, + text: "Continue the tracked work.", + reasoningEffort: "xhigh", + timeoutMs: 15_000, + }); + + const appended = vi.mocked(claudeSdkCreateSessionCompat).mock.calls + .map((call) => (call[0] as { systemPrompt?: { append?: string } } | undefined)?.systemPrompt?.append ?? "") + .join("\n"); + expect(appended).toContain("Linear-tracked work"); + expect(appended).toContain("ADE-123"); + expect(appended).toContain("ade linear"); + expect(appended).toContain("Prefer `ade linear`"); + }); + it("keeps ADE tooling guidance out of Claude SDK user turns", async () => { const send = vi.fn().mockResolvedValue(undefined); vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ @@ -2428,6 +2670,197 @@ describe("createAgentChatService", () => { }); }); + describe("launchHeadless", () => { + it("creates a session and fires the kickoff turn fire-and-forget without a mounted pane", async () => { + const send = vi.fn().mockResolvedValue(undefined); + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send, + stream: vi.fn(() => (async function* () { + yield { + type: "result", + subtype: "success", + is_error: false, + session_id: "sdk-headless", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + })()), + close: vi.fn(), + sessionId: "sdk-headless", + query: { + setPermissionMode: vi.fn(async () => undefined), + supportedCommands: vi.fn(async () => []), + }, + } as any); + + const { service, sessionService } = createService(); + const session = await service.launchHeadless({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + kickoffText: "Investigate the failing build and fix it.", + }); + + // createSession ran: a real session is returned and persisted, and + // launchHeadless returned it immediately. + expect(session).toBeDefined(); + expect(session.laneId).toBe("lane-1"); + expect(session.provider).toBe("claude"); + expect(sessionService.create).toHaveBeenCalledTimes(1); + + // The bug this fixes: with no mounted pane the kickoff never ran. Here the + // kickoff text reaches the SDK *after* launchHeadless already resolved, + // proving runSessionTurn fired fire-and-forget in the background. + await vi.waitFor(() => { + const payload = send.mock.calls + .map((call) => String(call[0] ?? "")) + .find((text) => text.includes("Investigate the failing build and fix it.")); + expect(payload).toBeTruthy(); + }); + }); + + it("returns the session even when the kickoff turn never settles", async () => { + // A turn that hangs forever would block the launch if it were awaited. + const send = vi.fn(() => new Promise(() => {})); + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send, + stream: vi.fn(() => (async function* () { + await new Promise(() => {}); + })()), + close: vi.fn(), + sessionId: "sdk-headless-pending", + query: { + setPermissionMode: vi.fn(async () => undefined), + supportedCommands: vi.fn(async () => []), + }, + } as any); + + const { service } = createService(); + // Resolves promptly despite the hanging turn -> fire-and-forget. + const session = await service.launchHeadless({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + kickoffText: "Start the work.", + }); + + expect(session).toBeDefined(); + }); + + it("defaults the session to autonomous full-auto when no permission controls are supplied", async () => { + // Map modes with the real semantics (full-auto => never / danger-full-access) + // so the derived native codex fields prove launchHeadless defaulted the + // session to full-auto — the only mode whose background turn never stalls + // on a permission prompt no pane could answer. + vi.mocked(mapPermissionToCodex).mockImplementation((mode) => { + if (mode === "full-auto") return { approvalPolicy: "never", sandbox: "danger-full-access" }; + if (mode === "edit") return { approvalPolicy: "untrusted", sandbox: "workspace-write" }; + return { approvalPolicy: "on-request", sandbox: "read-only" }; + }); + + const { service } = createService(); + const session = await service.launchHeadless({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5-codex", + modelId: "gpt-5-codex", + kickoffText: "Triage the incident.", + }); + + expect(session.codexApprovalPolicy).toBe("never"); + expect(session.codexSandbox).toBe("danger-full-access"); + expect(session.permissionMode).toBe("full-auto"); + }); + + it("honors an explicit permissionMode supplied by the caller", async () => { + vi.mocked(mapPermissionToCodex).mockImplementation((mode) => { + if (mode === "full-auto") return { approvalPolicy: "never", sandbox: "danger-full-access" }; + if (mode === "edit") return { approvalPolicy: "untrusted", sandbox: "workspace-write" }; + return { approvalPolicy: "on-request", sandbox: "read-only" }; + }); + + const { service } = createService(); + const session = await service.launchHeadless({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5-codex", + modelId: "gpt-5-codex", + permissionMode: "edit", + kickoffText: "Make a focused edit.", + }); + + // The caller's explicit mode wins over the full-auto default. + expect(session.codexApprovalPolicy).toBe("untrusted"); + expect(session.codexSandbox).toBe("workspace-write"); + expect(session.permissionMode).toBe("edit"); + }); + + it("persists the attached Linear issue link for a launched chat (FIX 1)", async () => { + // Regression: launchHeadless passed contextAttachments to runSessionTurn, + // but runSessionTurn dropped them before prepareSendMessage, so the + // session→issue link was never recorded and agents reached for Linear MCP. + const send = vi.fn().mockResolvedValue(undefined); + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send, + stream: vi.fn(() => (async function* () { + yield { + type: "result", + subtype: "success", + is_error: false, + session_id: "sdk-link", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + })()), + close: vi.fn(), + sessionId: "sdk-link", + query: { + setPermissionMode: vi.fn(async () => undefined), + supportedCommands: vi.fn(async () => []), + }, + } as any); + + const { service, laneService } = createService(); + const issue = makeLaneLinearIssue(); + const session = await service.launchHeadless({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + kickoffText: "Fix the bug tracked by this issue.", + contextAttachments: [makeLinearIssueContextAttachment(issue, "manual")], + }); + + // The session-scoped link is written so getSessionLinearEnv / the directive + // resolve the issue and the agent uses `ade linear` instead of MCP. + await vi.waitFor(() => { + expect(laneService.attachLinearIssueToSession).toHaveBeenCalledWith( + expect.objectContaining({ + chatSessionId: session.id, + issues: expect.arrayContaining([expect.objectContaining({ id: issue.id })]), + }), + ); + }); + expect(mockState.sessionLinearLinks.get(session.id)?.[0]?.issue?.id).toBe(issue.id); + }); + + it("tags the backing terminal row with its owning chat session id (FIX 2)", async () => { + // Regression: the chat's backing terminal was registered without a + // chatSessionId, so laneAgents.ts could not exclude it and a phantom "CLI" + // agent row appeared next to the chat row. + const { service, sessionService } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + }); + + expect(sessionService.create).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: session.id, + chatSessionId: session.id, + }), + ); + }); + }); + describe("handoffSession", () => { it("rejects handoff while the source chat is still outputting", async () => { const { service } = createService(); diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 9bf138c82..e8eec3a54 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -112,6 +112,7 @@ import type { AgentChatCodexConfigSource, AgentChatCodexSandbox, AgentChatCreateArgs, + AgentChatLaunchArgs, AgentChatContextUsage, AgentChatContextUsageArgs, AgentChatDeleteArgs, @@ -188,6 +189,7 @@ import type { TerminalToolType, CtoCapabilityMode, LaneLinearIssue, + SessionLinearIssueLink, } from "../../../shared/types"; import { buildChatContextAttachmentPrompt, @@ -3467,6 +3469,75 @@ function composeLaunchDirectives(baseText: string, directives: Array ({ + id: link.issue.id, + identifier: link.issue.identifier, + title: link.issue.title, + url: link.issue.url, + stateName: link.issue.stateName, + role: link.role, + teamKey: link.issue.teamKey, + })), + }; + fs.mkdirSync(sessionContextDir, { recursive: true }); + const tempPath = `${filePath}.tmp`; + fs.writeFileSync(tempPath, `${JSON.stringify(payload, null, 2)}\n`); + fs.renameSync(tempPath, filePath); + return { + filePath, + issueIds: args.links.map((link) => link.issue.id).join(","), + identifiers: args.links.map((link) => link.issue.identifier).join(","), + }; +} + +/** + * Build the system-prompt directive injected when a chat/CLI session has + * attached Linear issues. Tells the agent that ADE already owns the Linear + * connection so it should drive issues through the `ade linear` CLI rather than + * reaching for a Linear MCP/API (which it has no creds for). Returns null when + * the session has no attached issues. Pure transform — unit-tested directly. + */ +export function buildLinearSessionDirective( + links: SessionLinearIssueLink[], +): string | null { + const identifiers = uniqueNonEmpty(links.map((link) => link.issue.identifier)); + if (!identifiers.length) return null; + const idList = identifiers.join(", "); + // Keep this short and point at `ade linear --help` + the bundled ade-linear + // skill rather than enumerating the full surface — set-state needs a resolved + // workflow state-id, so the skill is the authoritative reference. + return [ + "## Linear-tracked work", + `This work is tracked by Linear issue(s) ${idList}. You have ADE's Linear connection — read and update them with the \`ade linear\` CLI (issue, comment, set-state, assign, label; run \`ade linear --help\` or see the bundled **ade-linear** skill for exact syntax).`, + "Prefer `ade linear` over any Linear MCP or direct Linear API — those are not authenticated in this environment.", + ].join("\n"); +} + export function buildComputerUseDirective( backendStatus: ComputerUseBackendStatus | null, ): string | null { @@ -4315,11 +4386,13 @@ function buildCodexDeveloperInstructions(args: { | "orchestrationStepId" >; collaborationMode: "default" | "plan"; + /** Optional Linear-tracked-work directive appended to the base instructions. */ + linearDirective?: string | null; }): string { const promptMode = args.collaborationMode === "plan" || args.session.interactionMode === "plan" ? "planning" : "coding"; - return buildCodingAgentSystemPrompt({ + const base = buildCodingAgentSystemPrompt({ cwd: args.laneWorktreePath, mode: promptMode, permissionMode: toHarnessPermissionMode(args.session.permissionMode), @@ -4333,6 +4406,7 @@ function buildCodexDeveloperInstructions(args: { orchestrationParentSessionId: args.session.orchestrationParentSessionId, orchestrationStepId: args.session.orchestrationStepId, }); + return args.linearDirective ? `${base}\n\n${args.linearDirective}` : base; } function resolveCodexInstructionCollaborationMode( @@ -4348,6 +4422,7 @@ function buildCodexCollaborationMode( >, supportedModes: Set | null, laneWorktreePath: string, + linearDirective?: string | null, ): CodexCollaborationModePayload | null { if (session.provider !== "codex") return null; if (resolveSessionCodexConfigSource(session) === "config-toml") return null; @@ -4370,6 +4445,7 @@ function buildCodexCollaborationMode( laneWorktreePath, session, collaborationMode: mode, + linearDirective, }), }, }; @@ -4946,13 +5022,73 @@ export function createAgentChatService(args: { } const claudeSubprocessReaper = injectedClaudeSubprocessReaper ?? createClaudeSubprocessReaper({ logger }); - const buildAgentRuntimeEnv = (managed: ManagedChatSession): NodeJS.ProcessEnv => ({ - ...(getAdeCliAgentEnv?.(process.env) ?? process.env), - ADE_CHAT_SESSION_ID: managed.session.id, - ADE_LANE_ID: managed.session.laneId, - ADE_PROJECT_ROOT: projectRoot, - ADE_WORKSPACE_ROOT: managed.laneWorktreePath, - }); + // Materialize the session's attached Linear issues into a per-session context + // file the spawned agent (ADE chat or CLI) can read without Linear creds. + // Returns the absolute file path and the comma-joined identifier list, or + // null when the session has no attached issues. Best-effort: a write failure + // never blocks the spawn. + const writeSessionLinearIssueContext = ( + sessionId: string, + ): { filePath: string; issueIds: string; identifiers: string } | null => { + let links: SessionLinearIssueLink[] = []; + try { + links = laneService.listLinearIssuesForSession?.({ chatSessionId: sessionId }) ?? []; + } catch (error) { + logger.warn("agent_chat.linear_context_read_failed", { + sessionId, + error: error instanceof Error ? error.message : String(error), + }); + return null; + } + try { + return writeSessionLinearIssueContextFile({ + contextDir: resolveAdeLayout(projectRoot).contextDir, + sessionId, + links, + now: nowIso(), + }); + } catch (error) { + logger.warn("agent_chat.linear_context_write_failed", { + sessionId, + error: error instanceof Error ? error.message : String(error), + }); + return null; + } + }; + + // Resolve the system-prompt directive for a session's attached Linear issues, + // or null when none are attached. Best-effort: a read failure never blocks the + // prompt build. Shared by the Claude and Codex prompt builders so both agents + // are steered toward `ade linear` over a (credential-less) Linear MCP/API. + const resolveSessionLinearDirective = (sessionId: string): string | null => { + let links: SessionLinearIssueLink[] = []; + try { + links = laneService.listLinearIssuesForSession?.({ chatSessionId: sessionId }) ?? []; + } catch (error) { + logger.warn("agent_chat.linear_directive_read_failed", { + sessionId, + error: error instanceof Error ? error.message : String(error), + }); + return null; + } + return buildLinearSessionDirective(links); + }; + + const buildAgentRuntimeEnv = (managed: ManagedChatSession): NodeJS.ProcessEnv => { + const env: NodeJS.ProcessEnv = { + ...(getAdeCliAgentEnv?.(process.env) ?? process.env), + ADE_CHAT_SESSION_ID: managed.session.id, + ADE_LANE_ID: managed.session.laneId, + ADE_PROJECT_ROOT: projectRoot, + ADE_WORKSPACE_ROOT: managed.laneWorktreePath, + }; + const linearContext = writeSessionLinearIssueContext(managed.session.id); + if (linearContext) { + env.ADE_LINEAR_ISSUE_IDS = linearContext.identifiers; + env.ADE_LINEAR_CONTEXT_FILE = linearContext.filePath; + } + return env; + }; const eventSubscribers = new Set<(event: AgentChatEventEnvelope) => void>(); @@ -5019,12 +5155,37 @@ export function createAgentChatService(args: { managed: ManagedChatSession, contextAttachments: AgentChatContextAttachment[], ): void => { - if (!managed.session.laneId || contextAttachments.length === 0) return; + if (contextAttachments.length === 0) return; const issues = contextAttachments .filter((attachment): attachment is Extract => attachment.type === "linear_issue") .map((attachment) => attachment.issue); if (!issues.length) return; + + // Always persist a SESSION-scoped link so standalone chats and CLI sessions + // (which may have no lane) keep their attached issues. When the session has + // a lane, attachLinearIssueToSession also mirrors into the lane-scoped link + // table — but it never promotes a lane's primary issue, so we still run the + // lane-scoped linkLinearIssues path below for the lane/card semantics. + try { + laneService.attachLinearIssueToSession?.({ + chatSessionId: managed.session.id, + issues, + role: "worked", + source: "chat_attach", + includeInPr: true, + closeOnMerge: false, + evidence: { chatSessionId: managed.session.id }, + }); + } catch (error) { + logger.warn("agent_chat.linear_issue_session_link_failed", { + sessionId: managed.session.id, + issueCount: issues.length, + error: error instanceof Error ? error.message : String(error), + }); + } + + if (!managed.session.laneId) return; try { laneService.linkLinearIssues({ laneId: managed.session.laneId, @@ -10023,6 +10184,7 @@ export function createAgentChatService(args: { managed.session, runtime.collaborationModes, managed.laneWorktreePath, + resolveSessionLinearDirective(managed.session.id), ); if ( requestedCollaborationMode === "plan" @@ -15266,6 +15428,7 @@ export function createAgentChatService(args: { laneWorktreePath: managed.laneWorktreePath, session: managed.session, collaborationMode: resolveCodexInstructionCollaborationMode(managed.session), + linearDirective: resolveSessionLinearDirective(managed.session.id), }), ...codexServiceTierArgs(managed.session), ...codexPolicyArgs(codexPolicy), @@ -15634,6 +15797,7 @@ export function createAgentChatService(args: { ] : []), ] : []; + const linearDirective = resolveSessionLinearDirective(managed.session.id); opts.systemPrompt = { type: "preset", preset: "claude_code", @@ -15647,6 +15811,7 @@ export function createAgentChatService(args: { `ADE launched this session in lane worktree: ${managed.laneWorktreePath}.`, "Read, edit, and run commands only inside that worktree. Do not switch to project root, another lane, or another repo unless ADE explicitly relaunches you there.", "", + ...(linearDirective ? [linearDirective, ""] : []), ...slashCommandsSection, "", buildAdeGuidanceForLane(managed.laneWorktreePath), @@ -16725,6 +16890,10 @@ export function createAgentChatService(args: { transcriptPath, toolType: toolTypeFromProvider(effectiveProvider), resumeCommand: resumeCommandForProvider(effectiveProvider, sessionId), + // Tag the backing terminal row with its owning chat session so the lane + // agent list (laneAgents.ts) excludes it instead of surfacing a phantom + // "CLI" agent alongside the chat row it belongs to. + chatSessionId: sessionId, ownerPid: processRegistry?.pid ?? null, ownerProcessStartedAt: processRegistry?.startedAt ?? null, }); @@ -19986,6 +20155,7 @@ export function createAgentChatService(args: { laneWorktreePath: managed.laneWorktreePath, session: managed.session, collaborationMode: resolveCodexInstructionCollaborationMode(managed.session), + linearDirective: resolveSessionLinearDirective(managed.session.id), }), ...codexServiceTierArgs(managed.session), ...codexPolicyArgs(codexPolicy), @@ -20065,6 +20235,7 @@ export function createAgentChatService(args: { return; } + const prevClaudeEffort = managed.session.reasoningEffort ?? null; const nextClaudeEffort = validateReasoningEffortForDescriptor( "claude", normalizeReasoningEffort(reasoningEffort), @@ -20073,6 +20244,26 @@ export function createAgentChatService(args: { if (nextClaudeEffort) { managed.session.reasoningEffort = nextClaudeEffort; } + // A warm/live query bakes in the effort it was built with (see + // buildClaudeQueryOptions). When this turn carries a different effort — + // e.g. the user picks xhigh after the session pre-warmed at medium — + // ensureClaudeQuery would otherwise reuse the stale warm query and the new + // effort would never reach the SDK. Invalidate it so a fresh query is built + // with the updated thinking configuration. Mirrors the explicit + // setOrchestrationFields effort-change path. + if ( + nextClaudeEffort + && nextClaudeEffort !== prevClaudeEffort + && managed.runtime?.kind === "claude" + && (managed.runtime.query || managed.runtime.warmQuery || managed.runtime.warmupDone) + ) { + if (managed.runtime.busy) { + managed.runtime.pendingSessionReset = true; + managed.runtime.pendingSessionResetClearSdkSessionId = false; + } else { + resetClaudeQuerySession(managed, managed.runtime, "session_reset"); + } + } ensureClaudeSessionRuntime(managed); await runClaudeTurn(managed, { @@ -24202,6 +24393,7 @@ export function createAgentChatService(args: { text, displayText, attachments = [], + contextAttachments = [], reasoningEffort, executionMode, timeoutMs, @@ -24242,6 +24434,7 @@ export function createAgentChatService(args: { text, displayText, attachments, + contextAttachments, reasoningEffort, executionMode, }); @@ -24649,8 +24842,52 @@ export function createAgentChatService(args: { persistChatState(managed); }; + /** + * Create a session and fire its first turn without a mounted chat pane. + * + * The interactive launch path (`sendMessage`) only drives a turn once a pane + * mounts and pumps the queue, so a batch launch that creates lanes but never + * opens panes would enqueue the kickoff and never run it. This mirrors the + * automation `agent-session` headless path: createSession + runSessionTurn. + * + * The kickoff turn is fire-and-forget — N long turns must not block the + * launch, so we return the created session immediately and let the turn run + * in the background (logging on failure). + */ + const launchHeadless = async ({ + kickoffText, + kickoffDisplayText, + contextAttachments, + ...createArgs + }: AgentChatLaunchArgs): Promise => { + // Default to full-auto (bypassPermissions) when the caller hasn't pinned a + // mode, so the background turn never stalls on a permission prompt that no + // mounted pane could answer. Identity-pinned sessions are locked to + // full-auto inside createSession regardless. + const session = await createSession({ + ...createArgs, + sessionProfile: createArgs.sessionProfile ?? "workflow", + permissionMode: createArgs.permissionMode ?? "full-auto", + }); + void runSessionTurn({ + sessionId: session.id, + text: kickoffText, + displayText: kickoffDisplayText ?? kickoffText, + contextAttachments: contextAttachments ?? [], + reasoningEffort: createArgs.reasoningEffort, + }).catch((err) => { + logger.warn("agentChat.launchHeadless turn failed", { + sessionId: session.id, + laneId: createArgs.laneId, + error: err instanceof Error ? err.message : String(err), + }); + }); + return session; + }; + return { createSession, + launchHeadless, suggestLaneNameFromPrompt, handoffSession, sendMessage, diff --git a/apps/desktop/src/main/services/cto/issueTracker.ts b/apps/desktop/src/main/services/cto/issueTracker.ts index db3006d82..eb683d7be 100644 --- a/apps/desktop/src/main/services/cto/issueTracker.ts +++ b/apps/desktop/src/main/services/cto/issueTracker.ts @@ -85,6 +85,8 @@ export type IssueTracker = { createComment(issueId: string, body: string): Promise; updateComment(commentId: string, body: string): Promise; addLabel(issueId: string, labelName: string): Promise; + addIssueLabel(issueId: string, labelId: string): Promise; + removeIssueLabel(issueId: string, labelId: string): Promise; uploadAttachment(args: { issueId: string; filePath: string; title?: string }): Promise<{ url: string; id?: string }>; createIssueAttachment(args: IssueTrackerIssueAttachmentInput): Promise<{ url: string; id?: string }>; fetchIssueComments(issueId: string): Promise { state: { type: { in: ["unstarted", "started"] } }, assignee: { id: { eq: "user-1" } }, priority: { eq: 2 }, + // Linear's IssueFilter has no `identifier` field, so a text query + // expands only to title/description (a numeric query would also add a + // `number` eq clause). or: [ { title: { containsIgnoreCase: "auth" } }, { description: { containsIgnoreCase: "auth" } }, - { identifier: { containsIgnoreCase: "auth" } }, ], }, }); diff --git a/apps/desktop/src/main/services/cto/linearClient.ts b/apps/desktop/src/main/services/cto/linearClient.ts index 7772ebac3..1bbc4a4df 100644 --- a/apps/desktop/src/main/services/cto/linearClient.ts +++ b/apps/desktop/src/main/services/cto/linearClient.ts @@ -619,11 +619,21 @@ export function createLinearClient(args: LinearClientArgs) { filter.priority = { eq: params.priority }; } if (query) { - filter.or = [ + const orClauses: Record[] = [ { title: { containsIgnoreCase: query } }, { description: { containsIgnoreCase: query } }, - { identifier: { containsIgnoreCase: query } }, ]; + // Linear's IssueFilter has no `identifier` field (that filter is rejected by + // the API). Match by issue number when the query is a bare number ("122") or + // an identifier like "VER-122" — pull the trailing digits and filter on it. + const numberMatch = query.match(/(\d+)\s*$/); + if (numberMatch) { + const parsedNumber = Number.parseInt(numberMatch[1]!, 10); + if (Number.isFinite(parsedNumber)) { + orClauses.push({ number: { eq: parsedNumber } }); + } + } + filter.or = orClauses; } return filter; @@ -1059,6 +1069,41 @@ export function createLinearClient(args: LinearClientArgs) { } }; + // Add/remove a label by its known label id (no name lookup). The CLI daemon + // bridge resolves a label id from `listLabels` first, then calls these — the + // name-based `addLabel` above is for callers that only have a label name. + const addIssueLabel = async (issueId: string, labelId: string): Promise => { + const trimmed = labelId.trim(); + if (!trimmed.length) return; + await request({ + query: ` + mutation AddIssueLabel($id: String!, $addedLabelIds: [String!]) { + issueUpdate(id: $id, input: { addedLabelIds: $addedLabelIds }) { + success + } + } + `, + variables: { id: issueId, addedLabelIds: [trimmed] }, + maxRetries: 1, + }); + }; + + const removeIssueLabel = async (issueId: string, labelId: string): Promise => { + const trimmed = labelId.trim(); + if (!trimmed.length) return; + await request({ + query: ` + mutation RemoveIssueLabel($id: String!, $removedLabelIds: [String!]) { + issueUpdate(id: $id, input: { removedLabelIds: $removedLabelIds }) { + success + } + } + `, + variables: { id: issueId, removedLabelIds: [trimmed] }, + maxRetries: 1, + }); + }; + const uploadAttachment = async (params: { issueId: string; filePath: string; title?: string }): Promise<{ url: string; id?: string }> => { const absPath = path.resolve(params.filePath); const stat = fs.statSync(absPath); @@ -1403,6 +1448,8 @@ export function createLinearClient(args: LinearClientArgs) { createComment, updateComment, addLabel, + addIssueLabel, + removeIssueLabel, uploadAttachment, createIssueAttachment, fetchIssueComments, diff --git a/apps/desktop/src/main/services/cto/linearIssueTracker.ts b/apps/desktop/src/main/services/cto/linearIssueTracker.ts index a19ea58ee..c37532640 100644 --- a/apps/desktop/src/main/services/cto/linearIssueTracker.ts +++ b/apps/desktop/src/main/services/cto/linearIssueTracker.ts @@ -64,6 +64,14 @@ export function createLinearIssueTracker(args: { client: LinearClient }): IssueT return args.client.addLabel(issueId, labelName); }, + addIssueLabel(issueId, labelId) { + return args.client.addIssueLabel(issueId, labelId); + }, + + removeIssueLabel(issueId, labelId) { + return args.client.removeIssueLabel(issueId, labelId); + }, + uploadAttachment(params) { return args.client.uploadAttachment(params); }, diff --git a/apps/desktop/src/main/services/cto/linearLiveStatusService.test.ts b/apps/desktop/src/main/services/cto/linearLiveStatusService.test.ts new file mode 100644 index 000000000..b7cbcccd1 --- /dev/null +++ b/apps/desktop/src/main/services/cto/linearLiveStatusService.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it, vi } from "vitest"; +import type { LaneLinearIssue } from "../../../shared/types"; +import type { IssueTracker } from "./issueTracker"; +import { createLinearLiveStatusService, isLinearLiveStatusRoundTripEnabled } from "./linearLiveStatusService"; + +function makeIssue(overrides: Partial = {}): LaneLinearIssue { + return { + id: "issue-1", + identifier: "ENG-1", + title: "Fix OAuth", + url: null, + projectId: "proj-1", + projectSlug: "eng", + teamId: "team-1", + teamKey: "ENG", + stateId: "state-backlog", + stateName: "Backlog", + stateType: "backlog", + priority: 0, + priorityLabel: "No priority", + labels: [], + assigneeId: null, + assigneeName: null, + branchName: "eng-1-fix-oauth", + createdAt: "2026-05-29T00:00:00.000Z", + updatedAt: "2026-05-29T00:00:00.000Z", + ...overrides, + } as LaneLinearIssue; +} + +function makeTracker(overrides: Partial = {}): IssueTracker { + return { + listWorkflowStates: vi.fn(async () => [ + { id: "state-progress", name: "In Progress", type: "started", teamId: "team-1", teamKey: "ENG" }, + { id: "state-done", name: "Done", type: "completed", teamId: "team-1", teamKey: "ENG" }, + ]), + updateIssueState: vi.fn(async () => {}), + updateIssueAssignee: vi.fn(async () => {}), + createComment: vi.fn(async () => ({ commentId: "c1" })), + getConnectionStatus: vi.fn(async () => ({ + connected: true, + viewerId: "viewer-1", + viewerName: "Me", + message: null, + })), + ...overrides, + } as unknown as IssueTracker; +} + +describe("isLinearLiveStatusRoundTripEnabled", () => { + it("is off unless the flag is exactly '1'", () => { + expect(isLinearLiveStatusRoundTripEnabled({})).toBe(false); + expect(isLinearLiveStatusRoundTripEnabled({ ADE_LINEAR_LIVE_STATUS_ROUNDTRIP: "0" })).toBe(false); + expect(isLinearLiveStatusRoundTripEnabled({ ADE_LINEAR_LIVE_STATUS_ROUNDTRIP: "1" })).toBe(true); + }); +}); + +describe("createLinearLiveStatusService", () => { + it("is a no-op when disabled", async () => { + const tracker = makeTracker(); + const service = createLinearLiveStatusService({ getIssueTracker: () => tracker, enabled: false }); + await service.onAgentLaunched({ issue: makeIssue() }); + await service.onPrOpened({ issueIds: ["issue-1"], prNumber: 7, githubUrl: "https://x/pr/7" }); + await service.onIssueMerged({ issues: [{ id: "issue-1", teamKey: "ENG" }] }); + expect(tracker.updateIssueState).not.toHaveBeenCalled(); + expect(tracker.createComment).not.toHaveBeenCalled(); + expect(tracker.updateIssueAssignee).not.toHaveBeenCalled(); + }); + + it("moves to In Progress, self-assigns, and comments the branch on launch", async () => { + const tracker = makeTracker(); + const service = createLinearLiveStatusService({ getIssueTracker: () => tracker, enabled: true }); + await service.onAgentLaunched({ issue: makeIssue(), laneName: "ade/eng-1" }); + expect(tracker.updateIssueState).toHaveBeenCalledWith("issue-1", "state-progress"); + expect(tracker.updateIssueAssignee).toHaveBeenCalledWith("issue-1", "viewer-1"); + expect(tracker.createComment).toHaveBeenCalledTimes(1); + expect((tracker.createComment as ReturnType).mock.calls[0][1]).toContain("eng-1-fix-oauth"); + }); + + it("does not re-move an issue already in the In Progress state", async () => { + const tracker = makeTracker(); + const service = createLinearLiveStatusService({ getIssueTracker: () => tracker, enabled: true }); + await service.onAgentLaunched({ issue: makeIssue({ stateId: "state-progress" }) }); + expect(tracker.updateIssueState).not.toHaveBeenCalled(); + }); + + it("only transitions an issue to In Progress once across launches", async () => { + const tracker = makeTracker(); + const service = createLinearLiveStatusService({ getIssueTracker: () => tracker, enabled: true }); + await service.onAgentLaunched({ issue: makeIssue() }); + await service.onAgentLaunched({ issue: makeIssue() }); + expect(tracker.updateIssueState).toHaveBeenCalledTimes(1); + }); + + it("comments the PR link on PR open", async () => { + const tracker = makeTracker(); + const service = createLinearLiveStatusService({ getIssueTracker: () => tracker, enabled: true }); + await service.onPrOpened({ issueIds: ["issue-1", "issue-1", ""], prNumber: 42, githubUrl: "https://gh/pr/42" }); + // Deduped + empties dropped → single comment. + expect(tracker.createComment).toHaveBeenCalledTimes(1); + expect((tracker.createComment as ReturnType).mock.calls[0][1]).toContain("https://gh/pr/42"); + }); + + it("moves to Done on merge", async () => { + const tracker = makeTracker(); + const service = createLinearLiveStatusService({ getIssueTracker: () => tracker, enabled: true }); + await service.onIssueMerged({ issues: [{ id: "issue-1", teamKey: "ENG", stateId: "state-progress" }] }); + expect(tracker.updateIssueState).toHaveBeenCalledWith("issue-1", "state-done"); + }); +}); diff --git a/apps/desktop/src/main/services/cto/linearLiveStatusService.ts b/apps/desktop/src/main/services/cto/linearLiveStatusService.ts new file mode 100644 index 000000000..4b05210f0 --- /dev/null +++ b/apps/desktop/src/main/services/cto/linearLiveStatusService.ts @@ -0,0 +1,202 @@ +import type { Logger } from "../logging/logger"; +import type { LaneLinearIssue } from "../../../shared/types"; +import type { IssueTracker } from "./issueTracker"; + +/** + * Live Linear status round-trip (Item 6 "extra", gated OFF by default). + * + * As an ADE agent moves an issue through its lifecycle we reflect that back into + * Linear: on launch the issue moves to the team's "In Progress" state, gets + * assigned to the connected user, and a branch-link comment is posted; on PR + * open a PR comment is posted; on merge the issue moves to "Done". Reuses the + * existing `issueTracker` write surface (`updateIssueState` / + * `updateIssueAssignee` / `createComment`) — no new Linear creds. + * + * Gating: enabled only when `ADE_LINEAR_LIVE_STATUS_ROUNDTRIP=1`. This keeps a + * potentially noisy, write-heavy behavior off until a user opts in, mirroring + * the `ADE_ENABLE_*` background-task flag convention in main.ts. + */ + +const LIVE_STATUS_ENV_FLAG = "ADE_LINEAR_LIVE_STATUS_ROUNDTRIP"; + +export function isLinearLiveStatusRoundTripEnabled(env: NodeJS.ProcessEnv = process.env): boolean { + return env[LIVE_STATUS_ENV_FLAG] === "1"; +} + +type WorkflowStateLite = { id: string; name: string; type: string }; + +export function createLinearLiveStatusService(args: { + getIssueTracker: () => IssueTracker | null; + /** Override the gate (tests inject `true`); defaults to the env flag. */ + enabled?: boolean; + logger?: Logger | null; +}) { + const logger = args.logger ?? null; + const enabled = args.enabled ?? isLinearLiveStatusRoundTripEnabled(); + + // Resolved once per team; workflow states rarely change within a session. + const statesByTeam = new Map(); + let viewerIdResolved = false; + let viewerId: string | null = null; + + // De-dupe transitions: a single issue should move to In Progress / Done at + // most once per direction even if multiple sessions launch against it. + const movedInProgress = new Set(); + const movedDone = new Set(); + + const warn = (event: string, fields: Record): void => { + logger?.warn(event, fields); + }; + + const resolveStatesForTeam = async ( + tracker: IssueTracker, + teamKey: string | null, + ): Promise => { + const key = (teamKey ?? "").trim() || "__all__"; + const cached = statesByTeam.get(key); + if (cached) return cached; + const states = await tracker.listWorkflowStates(teamKey ?? null); + const lite = states.map((state) => ({ id: state.id, name: state.name, type: state.type })); + statesByTeam.set(key, lite); + return lite; + }; + + const pickStateId = (states: WorkflowStateLite[], wantedType: "started" | "completed"): string | null => { + const match = states.find((state) => state.type === wantedType); + return match?.id ?? null; + }; + + const resolveViewerId = async (tracker: IssueTracker): Promise => { + if (viewerIdResolved) return viewerId; + viewerIdResolved = true; + try { + const status = await tracker.getConnectionStatus(); + viewerId = status.connected ? (status.viewerId ?? null) : null; + } catch (error) { + warn("linear_live_status.viewer_resolve_failed", { + error: error instanceof Error ? error.message : String(error), + }); + viewerId = null; + } + return viewerId; + }; + + /** On agent launch: In Progress + self-assign + branch-link comment. */ + const onAgentLaunched = async (input: { + issue: LaneLinearIssue; + branchName?: string | null; + laneName?: string | null; + }): Promise => { + if (!enabled) return; + const tracker = args.getIssueTracker(); + if (!tracker) return; + const issue = input.issue; + if (movedInProgress.has(issue.id)) return; + movedInProgress.add(issue.id); + + try { + const states = await resolveStatesForTeam(tracker, issue.teamKey); + const inProgressId = pickStateId(states, "started"); + if (inProgressId && inProgressId !== issue.stateId) { + await tracker.updateIssueState(issue.id, inProgressId); + } + } catch (error) { + movedInProgress.delete(issue.id); + warn("linear_live_status.in_progress_failed", { + issueId: issue.id, + issueIdentifier: issue.identifier, + error: error instanceof Error ? error.message : String(error), + }); + } + + try { + const viewer = await resolveViewerId(tracker); + if (viewer && issue.assigneeId !== viewer) { + await tracker.updateIssueAssignee(issue.id, viewer); + } + } catch (error) { + warn("linear_live_status.assign_failed", { + issueId: issue.id, + error: error instanceof Error ? error.message : String(error), + }); + } + + const branch = (input.branchName ?? issue.branchName ?? "").trim(); + if (branch) { + try { + const laneLabel = input.laneName?.trim() ? ` in lane "${input.laneName.trim()}"` : ""; + await tracker.createComment( + issue.id, + `ADE started an agent${laneLabel} on branch \`${branch}\`.`, + ); + } catch (error) { + warn("linear_live_status.launch_comment_failed", { + issueId: issue.id, + error: error instanceof Error ? error.message : String(error), + }); + } + } + }; + + /** On PR open: post a PR-link comment to each linked issue. */ + const onPrOpened = async (input: { + issueIds: string[]; + prNumber: number; + githubUrl: string; + }): Promise => { + if (!enabled) return; + const tracker = args.getIssueTracker(); + if (!tracker) return; + const url = input.githubUrl.trim(); + if (!url) return; + for (const issueId of new Set(input.issueIds.map((id) => id.trim()).filter(Boolean))) { + try { + await tracker.createComment(issueId, `ADE opened PR #${input.prNumber}: ${url}`); + } catch (error) { + warn("linear_live_status.pr_comment_failed", { + issueId, + prNumber: input.prNumber, + error: error instanceof Error ? error.message : String(error), + }); + } + } + }; + + /** On merge: move each linked issue to the team's Done state. */ + const onIssueMerged = async (input: { + issues: Array<{ id: string; teamKey?: string | null; stateId?: string | null }>; + }): Promise => { + if (!enabled) return; + const tracker = args.getIssueTracker(); + if (!tracker) return; + for (const issue of input.issues) { + const issueId = issue.id.trim(); + if (!issueId || movedDone.has(issueId)) continue; + movedDone.add(issueId); + try { + const states = await resolveStatesForTeam(tracker, issue.teamKey ?? null); + const doneId = pickStateId(states, "completed"); + if (doneId && doneId !== issue.stateId) { + await tracker.updateIssueState(issueId, doneId); + } + } catch (error) { + movedDone.delete(issueId); + warn("linear_live_status.done_failed", { + issueId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + }; + + return { + get enabled(): boolean { + return enabled; + }, + onAgentLaunched, + onPrOpened, + onIssueMerged, + }; +} + +export type LinearLiveStatusService = ReturnType; diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index d3262bbcf..e97da9fa7 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -22,6 +22,7 @@ import { resolveProjectIconPath, setProjectIconOverrideFromSelection, } from "../projects/projectIconResolver"; +import { launchAgentChatCli } from "../chat/agentChatCliLaunch"; import { runGit } from "../git/git"; import type { AdeCleanupResult, AdeProjectSnapshot, IosSimulatorWindowState } from "../../../shared/types"; import { toRecentProjectSummary } from "../projects/recentProjectSummary"; @@ -270,6 +271,9 @@ import type { AgentChatReloadClaudePluginsResult, AgentChatClaudePermissionMode, AgentChatCreateArgs, + AgentChatLaunchArgs, + AgentChatLaunchCliArgs, + AgentChatLaunchCliResult, AgentChatDeleteArgs, AgentChatGetSummaryArgs, AgentChatEventHistorySnapshot, @@ -321,6 +325,7 @@ import type { OnboardingStatus, OnboardingTourProgress, OnboardingTourVariant, + LaneLinearIssue, LaneListSnapshot, LaneSummary, ListOperationsArgs, @@ -394,6 +399,7 @@ import type { CancelResolverSessionArgs, RunTestSuiteArgs, SessionDeltaSummary, + SessionLinearIssueLink, StackChainItem, StopTestRunArgs, TerminalSessionDetail, @@ -4839,6 +4845,46 @@ export function registerIpc({ return await ctx.laneService.getChildren(arg.laneId); }); + ipcMain.handle(IPC.lanesAttachLinearIssueToSession, async ( + _event, + arg: { chatSessionId: string; issues: LaneLinearIssue[] }, + ): Promise => { + const ctx = ensureLaneContext(); + return ctx.laneService.attachLinearIssueToSession(arg); + }); + + ipcMain.handle(IPC.lanesDetachLinearIssueFromSession, async ( + _event, + arg: { chatSessionId: string; issueId?: string }, + ): Promise => { + const ctx = ensureLaneContext(); + return ctx.laneService.detachLinearIssueFromSession(arg); + }); + + ipcMain.handle(IPC.lanesListLinearIssuesForSession, async ( + _event, + arg: { chatSessionId: string }, + ): Promise => { + const ctx = ensureLaneContext(); + return ctx.laneService.listLinearIssuesForSession(arg); + }); + + ipcMain.handle(IPC.lanesListLinearIssuesForLaneSessions, async ( + _event, + arg: { laneId: string }, + ): Promise => { + const ctx = ensureLaneContext(); + return ctx.laneService.listLinearIssuesForLaneSessions(arg); + }); + + ipcMain.handle(IPC.lanesUnlinkLinearIssues, async ( + _event, + arg: { laneId: string; issueId?: string }, + ): Promise => { + const ctx = ensureLaneContext(); + return ctx.laneService.unlinkLinearIssues(arg); + }); + ipcMain.handle(IPC.lanesRebaseStart, async (_event, arg: RebaseStartArgs): Promise => { const ctx = ensureLaneContext(); return await ctx.laneService.rebaseStart(arg); @@ -5624,6 +5670,32 @@ export function registerIpc({ return await ctx.agentChatService.createSession(arg); }); + ipcMain.handle(IPC.agentChatLaunch, async (_event, arg: AgentChatLaunchArgs): Promise => { + const ctx = ensureAgentChatContext(); + return await ctx.agentChatService.launchHeadless(arg); + }); + + // Launch a tracked CLI/terminal agent with Linear issues attached *before* the + // process spawns. The new terminal's own session id doubles as the Linear + // link key, so `getSessionLinearEnv` injects ADE_LINEAR_ISSUE_IDS + + // ADE_LINEAR_CONTEXT_FILE into the PTY env (the agent reads/updates its issue + // via `ade linear`, no token needed). The kickoff prompt is built into the + // provider's startup command / initialInput. + ipcMain.handle(IPC.agentChatLaunchCli, async (_event, arg: AgentChatLaunchCliArgs): Promise => { + const ctx = getCtx(); + if (!ctx.laneService) { + throw new Error("agentChat.launchCli requires an active project runtime lane service."); + } + if (!ctx.ptyService) { + throw new Error("agentChat.launchCli requires an active terminal (pty) service."); + } + return launchAgentChatCli(arg, { + laneService: ctx.laneService, + ptyService: ctx.ptyService, + logger: ctx.logger, + }); + }); + ipcMain.handle(IPC.agentChatSuggestLaneName, async (_event, arg: unknown): Promise => { const ctx = ensureAgentChatContext(); return await ctx.agentChatService.suggestLaneNameFromPrompt(parseAgentChatSuggestLaneNameArgs(arg)); diff --git a/apps/desktop/src/main/services/lanes/laneService.test.ts b/apps/desktop/src/main/services/lanes/laneService.test.ts index 9af0f8172..42d5134ff 100644 --- a/apps/desktop/src/main/services/lanes/laneService.test.ts +++ b/apps/desktop/src/main/services/lanes/laneService.test.ts @@ -3149,6 +3149,106 @@ describe("laneService delete teardown + cancellation + streaming", () => { expect(db.get<{ id: string }>("select id from lanes where id = ?", ["lane-child"])).toBeNull(); }); + it("keeps the lane visible when required remote branch cleanup fails", async () => { + const events: any[] = []; + const fake = makeFakeServices(); + const { service, db } = await setupWithLane({ teardown: fake, events, createWorktree: false }); + vi.mocked(runGit).mockImplementation(async (args: string[]) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return laneBranchGitStub; + if (args[0] === "show-ref") return { exitCode: 0, stdout: "", stderr: "" } as any; + if (args[0] === "remote" && args[1] === "get-url") return { exitCode: 0, stdout: "git@example.test/repo.git\n", stderr: "" } as any; + if (args[0] === "ls-remote") return { exitCode: 0, stdout: "abc\trefs/heads/feature/child\n", stderr: "" } as any; + return { exitCode: 0, stdout: "", stderr: "" } as any; + }); + vi.mocked(runGitOrThrow).mockImplementation(async (args: string[]) => { + if (args[0] === "push") throw new Error("remote rejected delete"); + return { exitCode: 0, stdout: "", stderr: "" } as any; + }); + + await expect( + service.delete({ + laneId: "lane-child", + deleteBranch: true, + deleteRemoteBranch: true, + requireRemoteBranchDelete: true, + remoteName: "origin", + }), + ).rejects.toThrow("remote rejected delete"); + + const last = events[events.length - 1]; + expect(last.progress.overallStatus).toBe("failed"); + expect(last.progress.steps.find((s: any) => s.name === "git_branch_delete")?.status).toBe("completed"); + const remoteStep = last.progress.steps.find((s: any) => s.name === "git_remote_branch_delete"); + expect(remoteStep?.status).toBe("failed"); + expect(remoteStep?.errorMessage).toContain("remote rejected delete"); + expect(db.get<{ id: string }>("select id from lanes where id = ?", ["lane-child"])?.id).toBe("lane-child"); + }); + + it("normalizes remote-shaped lane refs before deleting local and remote branches", async () => { + const events: any[] = []; + const fake = makeFakeServices(); + const { service, db } = await setupWithLane({ teardown: fake, events, createWorktree: false }); + db.run("update lanes set branch_ref = ? where id = ?", ["origin/feature/child", "lane-child"]); + const gitOrThrowCalls: string[][] = []; + vi.mocked(runGit).mockImplementation(async (args: string[]) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return laneBranchGitStub; + if (args[0] === "show-ref") return { exitCode: 0, stdout: "", stderr: "" } as any; + if (args[0] === "remote" && args[1] === "get-url") return { exitCode: 0, stdout: "git@example.test/repo.git\n", stderr: "" } as any; + if (args[0] === "ls-remote") return { exitCode: 0, stdout: "abc\trefs/heads/feature/child\n", stderr: "" } as any; + return { exitCode: 0, stdout: "", stderr: "" } as any; + }); + vi.mocked(runGitOrThrow).mockImplementation(async (args: string[]) => { + gitOrThrowCalls.push(args); + return { exitCode: 0, stdout: "", stderr: "" } as any; + }); + + await service.delete({ + laneId: "lane-child", + deleteBranch: true, + deleteRemoteBranch: true, + remoteName: "origin", + }); + + expect(runGit).toHaveBeenCalledWith( + ["ls-remote", "--heads", "origin", "feature/child"], + expect.objectContaining({ timeoutMs: 30_000 }), + ); + expect(gitOrThrowCalls).toContainEqual(["branch", "-D", "feature/child"]); + expect(gitOrThrowCalls).toContainEqual(["push", "origin", "--delete", "feature/child"]); + const last = events[events.length - 1]; + expect(last.progress.overallStatus).toBe("completed"); + }); + + it("surfaces remote branch lookup failures as delete warnings", async () => { + const events: any[] = []; + const fake = makeFakeServices(); + const { service } = await setupWithLane({ teardown: fake, events, createWorktree: false }); + vi.mocked(runGit).mockImplementation(async (args: string[]) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return laneBranchGitStub; + if (args[0] === "show-ref") return { exitCode: 0, stdout: "", stderr: "" } as any; + if (args[0] === "remote" && args[1] === "get-url") return { exitCode: 0, stdout: "git@example.test/repo.git\n", stderr: "" } as any; + if (args[0] === "ls-remote") return { exitCode: 128, stdout: "", stderr: "Could not read from remote repository.\n" } as any; + return { exitCode: 0, stdout: "", stderr: "" } as any; + }); + vi.mocked(runGitOrThrow).mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" } as any); + + await service.delete({ + laneId: "lane-child", + deleteBranch: true, + deleteRemoteBranch: true, + remoteName: "origin", + }); + + const last = events[events.length - 1]; + expect(last.progress.overallStatus).toBe("completed_with_warnings"); + const remoteStep = last.progress.steps.find((s: any) => s.name === "git_remote_branch_delete"); + expect(remoteStep?.status).toBe("warning"); + expect(remoteStep?.errorMessage).toContain("Could not read from remote repository"); + }); + it("cleans lane-owned database state when deleting a lane", async () => { const events: any[] = []; const fake = makeFakeServices(); @@ -3989,3 +4089,432 @@ describe("laneService - branchSwitch", () => { }); }); }); + +describe("laneService session-scoped Linear issue links", () => { + function seedClaudeSession(db: any, args: { sessionId: string; laneId: string }) { + const now = "2026-05-20T10:00:00.000Z"; + db.run( + ` + insert into claude_sessions(session_id, lane_id, chat_session_id, title, tags_json, created_at, updated_at) + values (?, ?, ?, ?, ?, ?, ?) + `, + [args.sessionId, args.laneId, null, null, null, now, now], + ); + } + + it("persists and lists Linear issues attached to a standalone session with no lane", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-session-standalone-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + try { + await seedProjectAndStack(db, { projectId: "proj-session-standalone", repoRoot }); + const service = createLaneService({ + db, + projectRoot: repoRoot, + projectId: "proj-session-standalone", + defaultBaseRef: "main", + worktreesDir: path.join(repoRoot, "worktrees"), + }); + + // "chat-no-lane" is not present in claude_sessions/terminal_sessions, so + // it resolves to no lane — the standalone case. + const links = service.attachLinearIssueToSession({ + chatSessionId: "chat-no-lane", + issues: [makeLinearIssue()], + }); + + expect(links).toHaveLength(1); + expect(links[0]?.sessionId).toBe("chat-no-lane"); + expect(links[0]?.laneId).toBeNull(); + expect(links[0]?.role).toBe("worked"); + expect(links[0]?.source).toBe("chat_attach"); + expect(links[0]?.issue.identifier).toBe("ABC-42"); + + const listed = service.listLinearIssuesForSession({ chatSessionId: "chat-no-lane" }); + expect(listed).toHaveLength(1); + expect(listed[0]?.issue.id).toBe("issue-1"); + + // No lane → nothing mirrored into the lane-scoped link table. + expect(service.listLinearIssuesForLaneSessions({ laneId: "lane-child" })).toHaveLength(0); + } finally { + db.close(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("attaches multiple issues in one batch call", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-session-batch-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + try { + await seedProjectAndStack(db, { projectId: "proj-session-batch", repoRoot }); + const service = createLaneService({ + db, + projectRoot: repoRoot, + projectId: "proj-session-batch", + defaultBaseRef: "main", + worktreesDir: path.join(repoRoot, "worktrees"), + }); + + const links = service.attachLinearIssueToSession({ + chatSessionId: "chat-batch", + issues: [ + makeLinearIssue(), + { ...makeLinearIssue(), id: "issue-2", identifier: "ABC-43", title: "Second" }, + // Duplicate id is deduped. + { ...makeLinearIssue(), title: "Dupe id" }, + ], + }); + + expect(links).toHaveLength(2); + expect(service.listLinearIssuesForSession({ chatSessionId: "chat-batch" })).toHaveLength(2); + } finally { + db.close(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("mirrors a session attach into the lane link table when the session has a lane", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-session-laned-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + try { + await seedProjectAndStack(db, { projectId: "proj-session-laned", repoRoot }); + seedClaudeSession(db, { sessionId: "chat-on-child", laneId: "lane-child" }); + const service = createLaneService({ + db, + projectRoot: repoRoot, + projectId: "proj-session-laned", + defaultBaseRef: "main", + worktreesDir: path.join(repoRoot, "worktrees"), + }); + + const links = service.attachLinearIssueToSession({ + chatSessionId: "chat-on-child", + issues: [makeLinearIssue()], + }); + expect(links[0]?.laneId).toBe("lane-child"); + + // Mirrored into lane_linear_issue_links so the lane surfaces the issue. + const lanes = await service.list({ includeStatus: false }); + const child = lanes.find((lane) => lane.id === "lane-child"); + expect(child?.linearIssueLinks).toEqual(expect.arrayContaining([ + expect.objectContaining({ + source: "chat_attach", + issue: expect.objectContaining({ identifier: "ABC-42" }), + }), + ])); + + // And aggregated by lane across its sessions. + const laneSessionLinks = service.listLinearIssuesForLaneSessions({ laneId: "lane-child" }); + expect(laneSessionLinks).toHaveLength(1); + expect(laneSessionLinks[0]?.sessionId).toBe("chat-on-child"); + } finally { + db.close(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("detaches an issue from both the session and the mirrored lane link", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-session-detach-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + try { + await seedProjectAndStack(db, { projectId: "proj-session-detach", repoRoot }); + seedClaudeSession(db, { sessionId: "chat-detach", laneId: "lane-child" }); + const service = createLaneService({ + db, + projectRoot: repoRoot, + projectId: "proj-session-detach", + defaultBaseRef: "main", + worktreesDir: path.join(repoRoot, "worktrees"), + }); + + service.attachLinearIssueToSession({ chatSessionId: "chat-detach", issues: [makeLinearIssue()] }); + expect(service.listLinearIssuesForSession({ chatSessionId: "chat-detach" })).toHaveLength(1); + expect(service.listLinearIssuesForLaneSessions({ laneId: "lane-child" })).toHaveLength(1); + + const detached = service.detachLinearIssueFromSession({ chatSessionId: "chat-detach", issueId: "issue-1" }); + expect(detached).toBe(true); + expect(service.listLinearIssuesForSession({ chatSessionId: "chat-detach" })).toHaveLength(0); + expect(service.listLinearIssuesForLaneSessions({ laneId: "lane-child" })).toHaveLength(0); + + const lanes = await service.list({ includeStatus: false }); + const child = lanes.find((lane) => lane.id === "lane-child"); + expect((child?.linearIssueLinks ?? []).some((l) => l.source === "chat_attach")).toBe(false); + + // Detaching an unknown issue is a no-op. + expect(service.detachLinearIssueFromSession({ chatSessionId: "chat-detach", issueId: "issue-1" })).toBe(false); + } finally { + db.close(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("detaches all issues from a session when issueId is omitted", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-session-detach-all-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + try { + await seedProjectAndStack(db, { projectId: "proj-session-detach-all", repoRoot }); + const service = createLaneService({ + db, + projectRoot: repoRoot, + projectId: "proj-session-detach-all", + defaultBaseRef: "main", + worktreesDir: path.join(repoRoot, "worktrees"), + }); + + service.attachLinearIssueToSession({ + chatSessionId: "chat-all", + issues: [ + makeLinearIssue(), + { ...makeLinearIssue(), id: "issue-2", identifier: "ABC-43", title: "Second" }, + ], + }); + expect(service.listLinearIssuesForSession({ chatSessionId: "chat-all" })).toHaveLength(2); + + const detached = service.detachLinearIssueFromSession({ chatSessionId: "chat-all" }); + expect(detached).toBe(true); + expect(service.listLinearIssuesForSession({ chatSessionId: "chat-all" })).toHaveLength(0); + } finally { + db.close(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("re-attaching the same issue+role replaces rather than duplicates", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-session-dedupe-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + try { + await seedProjectAndStack(db, { projectId: "proj-session-dedupe", repoRoot }); + const service = createLaneService({ + db, + projectRoot: repoRoot, + projectId: "proj-session-dedupe", + defaultBaseRef: "main", + worktreesDir: path.join(repoRoot, "worktrees"), + }); + + service.attachLinearIssueToSession({ chatSessionId: "chat-dupe", issues: [makeLinearIssue()] }); + service.attachLinearIssueToSession({ + chatSessionId: "chat-dupe", + issues: [{ ...makeLinearIssue(), stateName: "Done", stateType: "completed" }], + }); + + const listed = service.listLinearIssuesForSession({ chatSessionId: "chat-dupe" }); + expect(listed).toHaveLength(1); + // Latest write wins. + expect(listed[0]?.issue.stateName).toBe("Done"); + } finally { + db.close(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("skips issues missing required fields without persisting them", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-session-invalid-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + try { + await seedProjectAndStack(db, { projectId: "proj-session-invalid", repoRoot }); + const service = createLaneService({ + db, + projectRoot: repoRoot, + projectId: "proj-session-invalid", + defaultBaseRef: "main", + worktreesDir: path.join(repoRoot, "worktrees"), + }); + + // Mirrors linkLinearIssues: unlinkable issues are skipped, not thrown. + const links = service.attachLinearIssueToSession({ + chatSessionId: "chat-invalid", + issues: [{ ...makeLinearIssue(), id: "", identifier: "" }], + }); + expect(links).toHaveLength(0); + expect(service.listLinearIssuesForSession({ chatSessionId: "chat-invalid" })).toHaveLength(0); + } finally { + db.close(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); +}); + +describe("laneService unlinkLinearIssues", () => { + function seedPrimaryLinearIssue(db: any, args: { projectId: string; laneId: string; issue: ReturnType }) { + const now = "2026-05-20T10:00:00.000Z"; + db.run( + ` + insert into lane_linear_issues(id, project_id, lane_id, issue_id, issue_json, created_at, updated_at) + values (?, ?, ?, ?, ?, ?, ?) + `, + [`primary-${args.laneId}`, args.projectId, args.laneId, args.issue.id, JSON.stringify(args.issue), now, now], + ); + } + + it("removes a specific non-primary lane link, leaving the primary intact", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-unlink-one-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + try { + await seedProjectAndStack(db, { projectId: "proj-unlink-one", repoRoot }); + const primaryIssue = { ...makeLinearIssue(), id: "issue-primary", identifier: "ABC-1", title: "Primary" }; + seedPrimaryLinearIssue(db, { projectId: "proj-unlink-one", laneId: "lane-child", issue: primaryIssue }); + const service = createLaneService({ + db, + projectRoot: repoRoot, + projectId: "proj-unlink-one", + defaultBaseRef: "main", + worktreesDir: path.join(repoRoot, "worktrees"), + }); + + service.linkLinearIssues({ + laneId: "lane-child", + issues: [ + { ...makeLinearIssue(), id: "issue-a", identifier: "ABC-2", title: "A" }, + { ...makeLinearIssue(), id: "issue-b", identifier: "ABC-3", title: "B" }, + ], + }); + + const removed = service.unlinkLinearIssues({ laneId: "lane-child", issueId: "issue-a" }); + expect(removed).toBe(true); + + const lanes = await service.list({ includeStatus: false }); + const child = lanes.find((lane) => lane.id === "lane-child"); + const linkIds = (child?.linearIssueLinks ?? []).map((l) => l.issue.id); + expect(linkIds).not.toContain("issue-a"); + expect(linkIds).toContain("issue-b"); + // Primary survives. + expect(child?.linearIssue?.id).toBe("issue-primary"); + expect(linkIds).toContain("issue-primary"); + } finally { + db.close(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("removes all non-primary links when issueId is omitted but never the primary", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-unlink-all-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + try { + await seedProjectAndStack(db, { projectId: "proj-unlink-all", repoRoot }); + const primaryIssue = { ...makeLinearIssue(), id: "issue-primary", identifier: "ABC-1", title: "Primary" }; + seedPrimaryLinearIssue(db, { projectId: "proj-unlink-all", laneId: "lane-child", issue: primaryIssue }); + const service = createLaneService({ + db, + projectRoot: repoRoot, + projectId: "proj-unlink-all", + defaultBaseRef: "main", + worktreesDir: path.join(repoRoot, "worktrees"), + }); + + service.linkLinearIssues({ + laneId: "lane-child", + issues: [ + { ...makeLinearIssue(), id: "issue-a", identifier: "ABC-2", title: "A" }, + { ...makeLinearIssue(), id: "issue-b", identifier: "ABC-3", title: "B" }, + ], + }); + + const removed = service.unlinkLinearIssues({ laneId: "lane-child" }); + expect(removed).toBe(true); + + const lanes = await service.list({ includeStatus: false }); + const child = lanes.find((lane) => lane.id === "lane-child"); + const linkIds = (child?.linearIssueLinks ?? []).map((l) => l.issue.id); + expect(linkIds).not.toContain("issue-a"); + expect(linkIds).not.toContain("issue-b"); + // Primary is preserved (synthesized as a link + the lane primary issue). + expect(child?.linearIssue?.id).toBe("issue-primary"); + + // Nothing left to remove → no-op. + expect(service.unlinkLinearIssues({ laneId: "lane-child" })).toBe(false); + } finally { + db.close(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); +}); + +describe("laneService createWorktreeLane orphan cleanup", () => { + beforeEach(() => { + vi.mocked(getHeadSha).mockReset(); + vi.mocked(runGit).mockReset(); + vi.mocked(runGitOrThrow).mockReset(); + }); + + it("removes the created worktree and branch when the lane-row insert fails", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-create-cleanup-")); + const realDb = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + const now = "2026-05-12T20:00:00.000Z"; + realDb.run( + "insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) values (?, ?, ?, ?, ?, ?)", + ["proj-create-cleanup", repoRoot, "demo", "main", now, now], + ); + + // Inject a failure on the `insert into lanes(...)` AFTER `git worktree add` + // already succeeded, simulating a DB write that orphans the worktree. + const db = new Proxy(realDb, { + get(target, prop, receiver) { + if (prop === "run") { + return (sql: string, params?: unknown[]) => { + if (/insert into lanes\(/i.test(sql)) { + throw new Error("simulated lane insert failure"); + } + return (target.run as (sql: string, params?: unknown[]) => void)(sql, params as never); + }; + } + return Reflect.get(target, prop, receiver); + }, + }); + + let worktreeAdded = false; + let worktreeRemoved = false; + let branchDeleted = false; + + vi.mocked(runGitOrThrow).mockImplementation(async (args: string[]) => { + if (args[0] === "worktree" && args[1] === "add") { + worktreeAdded = true; + return { exitCode: 0, stdout: "", stderr: "" } as any; + } + if (args[0] === "worktree" && args[1] === "remove") { + worktreeRemoved = true; + return { exitCode: 0, stdout: "", stderr: "" } as any; + } + return { exitCode: 0, stdout: "", stderr: "" } as any; + }); + + vi.mocked(runGit).mockImplementation(async (args: string[]) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return laneBranchGitStub; + if (args[0] === "rev-parse" && args[1] === "main") return { exitCode: 0, stdout: "sha-main\n", stderr: "" }; + if (args[0] === "branch" && args[1] === "-D") { + branchDeleted = true; + return { exitCode: 0, stdout: "", stderr: "" }; + } + return { exitCode: 0, stdout: "", stderr: "" }; + }); + + const service = createLaneService({ + db, + projectRoot: repoRoot, + projectId: "proj-create-cleanup", + defaultBaseRef: "main", + worktreesDir: path.join(repoRoot, "worktrees"), + }); + + try { + await expect( + service.create({ name: "ENG-9 Orphan guard" }), + ).rejects.toThrow(/simulated lane insert failure/); + + // The worktree was created, so cleanup must remove it and the branch. + expect(worktreeAdded).toBe(true); + expect(worktreeRemoved).toBe(true); + expect(branchDeleted).toBe(true); + + // No lane row was persisted for the failed create. + const rows = realDb.all<{ id: string }>( + "select id from lanes where project_id = ?", + ["proj-create-cleanup"], + ); + expect(rows).toHaveLength(0); + } finally { + realDb.close(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index b099f4ac8..5ad85aad3 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -65,6 +65,7 @@ import type { RebaseStartResult, RebasePushArgs, PushMode, + SessionLinearIssueLink, StackChainItem, UnregisteredLaneCandidate, UpdateLaneAppearanceArgs @@ -138,6 +139,22 @@ type LaneLinearIssueLinkRow = { updated_at: string; }; +type SessionLinearIssueLinkRow = { + id: string; + project_id: string; + session_id: string; + lane_id: string | null; + issue_id: string; + issue_json: string; + role: string; + source: string; + include_in_pr: number; + close_on_merge: number; + evidence_json: string | null; + created_at: string; + updated_at: string; +}; + const DEFAULT_LANE_STATUS: LaneStatus = { dirty: false, ahead: 0, behind: 0, remoteBehind: -1, rebaseInProgress: false }; const LANE_LIST_CACHE_TTL_MS = 10_000; @@ -386,6 +403,25 @@ function parseLaneLinearIssueLink(row: LaneLinearIssueLinkRow | null | undefined }; } +function parseSessionLinearIssueLink(row: SessionLinearIssueLinkRow | null | undefined): SessionLinearIssueLink | null { + if (!row) return null; + const issue = parseLaneLinearIssueJson(row.issue_json); + if (!issue) return null; + return { + id: row.id, + sessionId: row.session_id, + laneId: row.lane_id ?? null, + issue, + role: normalizeLaneLinearIssueLinkRole(row.role), + source: normalizeLaneLinearIssueLinkSource(row.source), + includeInPr: row.include_in_pr === 1, + closeOnMerge: row.close_on_merge === 1, + evidence: parseIssueLinkEvidence(row.evidence_json), + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + function makePrimaryLinearIssueLink(laneId: string, issue: LaneLinearIssue, timestamp: string): LaneLinearIssueLink { return { id: `primary:${laneId}:${issue.id}`, @@ -796,6 +832,18 @@ function isActiveProcess(p: ProcessRuntime): boolean { const LANE_DELETE_PROGRESS_HISTORY_TTL_MS = 60_000; +function branchNameForDelete(branchRef: string, remoteName = "origin"): string { + const trimmed = branchRef.trim().replace(/^refs\/heads\//, ""); + if (!trimmed) return ""; + if (trimmed.startsWith("refs/remotes/")) { + const rest = trimmed.slice("refs/remotes/".length); + const remotePrefix = `${remoteName.trim() || "origin"}/`; + return rest.startsWith(remotePrefix) ? rest.slice(remotePrefix.length) : rest; + } + const remotePrefix = `${remoteName.trim() || "origin"}/`; + return trimmed.startsWith(remotePrefix) ? trimmed.slice(remotePrefix.length) : trimmed; +} + function cloneLaneDeleteProgress(progress: LaneDeleteProgress): LaneDeleteProgress { return { ...progress, @@ -1100,6 +1148,127 @@ export function createLaneService({ } }; + // Resolve the lane a chat/CLI session belongs to, if any. Chat sessions live + // in `claude_sessions` (keyed by session_id); CLI/terminal sessions in + // `terminal_sessions` (keyed by id). Standalone chats / `ade chat` sessions + // may have no lane, in which case this returns null. + const resolveSessionLaneId = (sessionId: string): string | null => { + const id = sessionId.trim(); + if (!id) return null; + try { + const chat = db.get<{ lane_id: string | null }>( + "select lane_id from claude_sessions where session_id = ? limit 1", + [id], + ); + if (chat?.lane_id && getLaneRow(chat.lane_id)) return chat.lane_id; + } catch { + // claude_sessions may be unavailable in some runtime modes; fall through. + } + try { + const terminal = db.get<{ lane_id: string | null }>( + "select lane_id from terminal_sessions where id = ? limit 1", + [id], + ); + if (terminal?.lane_id && getLaneRow(terminal.lane_id)) return terminal.lane_id; + } catch { + // terminal_sessions may be unavailable in some runtime modes. + } + return null; + }; + + const getSessionLinearIssueLinks = (sessionId: string): SessionLinearIssueLink[] => { + const id = sessionId.trim(); + if (!id) return []; + try { + return db.all( + ` + select * + from session_linear_issues + where project_id = ? + and session_id = ? + order by + case role when 'primary' then 0 when 'worked' then 1 when 'referenced' then 2 else 3 end, + updated_at desc + `, + [projectId, id], + ).map(parseSessionLinearIssueLink).filter((link): link is SessionLinearIssueLink => Boolean(link)); + } catch { + return []; + } + }; + + const upsertSessionLinearIssueLink = (args: { + sessionId: string; + laneId?: string | null; + issue: LaneLinearIssue; + role: LaneLinearIssueLinkRole; + source: LaneLinearIssueLinkSource; + includeInPr?: boolean; + closeOnMerge?: boolean; + evidence?: SessionLinearIssueLink["evidence"]; + }): SessionLinearIssueLink => { + const sessionId = args.sessionId.trim(); + const laneId = args.laneId?.trim() || null; + const now = new Date().toISOString(); + const includeInPr = args.includeInPr !== false; + const closeOnMerge = args.closeOnMerge === true; + db.run("begin"); + try { + db.run( + ` + delete from session_linear_issues + where project_id = ? + and session_id = ? + and issue_id = ? + and role = ? + `, + [projectId, sessionId, args.issue.id, args.role], + ); + const id = randomUUID(); + db.run( + ` + insert into session_linear_issues( + id, project_id, session_id, lane_id, issue_id, issue_json, role, source, + include_in_pr, close_on_merge, evidence_json, created_at, updated_at + ) + values(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + id, + projectId, + sessionId, + laneId, + args.issue.id, + JSON.stringify(args.issue), + args.role, + args.source, + includeInPr ? 1 : 0, + closeOnMerge ? 1 : 0, + args.evidence ? JSON.stringify(args.evidence) : null, + now, + now, + ], + ); + db.run("commit"); + return { + id, + sessionId, + laneId, + issue: cloneLaneLinearIssue(args.issue), + role: args.role, + source: args.source, + includeInPr, + closeOnMerge, + evidence: args.evidence ?? null, + createdAt: now, + updatedAt: now, + }; + } catch (err) { + try { db.run("rollback"); } catch { /* keep original issue-link error */ } + throw err; + } + }; + const getAllLaneRows = (includeArchived = false) => db.all( includeArchived @@ -1324,34 +1493,48 @@ export function createLaneService({ throw new Error(`Generated branch name "${branchRef}" is not valid.`); } - const localExists = await runGit(["show-ref", "--verify", "--quiet", `refs/heads/${branchRef}`], { - cwd: projectRoot, - timeoutMs: 8_000, - }).then((res) => res.exitCode === 0); - if (localExists) { - throw new Error(`Branch "${branchRef}" already exists locally.`); - } - - const remoteCollisionMessage = isLinearBranch - ? `Branch "origin/${branchRef}" already exists on the remote. Detach the Linear issue or choose one whose branch name is unused.` - : `Branch "origin/${branchRef}" already exists on the remote. Choose a different branch name.`; - - if (isCustomBranch) { - const remoteTrackingExists = await runGit(["show-ref", "--verify", "--quiet", `refs/remotes/origin/${branchRef}`], { + // Does this ref already exist locally, as a remote-tracking ref, or on the + // actual remote? Used to detect collisions before `git worktree add -b`. + const branchIsTaken = async (ref: string): Promise => { + const localExists = await runGit(["show-ref", "--verify", "--quiet", `refs/heads/${ref}`], { cwd: projectRoot, timeoutMs: 8_000, }).then((res) => res.exitCode === 0); - if (remoteTrackingExists) { - throw new Error(remoteCollisionMessage); - } - - const remoteExists = await runGit(["ls-remote", "--heads", "origin", branchRef], { + if (localExists) return true; + // Only auto-named (slug / Linear) branches probe the remote — explicit + // user branches are checked separately so the error can be specific. + if (!isCustomBranch) return false; + const remoteTrackingExists = await runGit(["show-ref", "--verify", "--quiet", `refs/remotes/origin/${ref}`], { + cwd: projectRoot, + timeoutMs: 8_000, + }).then((res) => res.exitCode === 0); + if (remoteTrackingExists) return true; + return runGit(["ls-remote", "--heads", "origin", ref], { cwd: projectRoot, timeoutMs: 15_000, }).then((res) => res.exitCode === 0 && res.stdout.trim().length > 0); - if (remoteExists) { - throw new Error(remoteCollisionMessage); + }; + + // Linear-derived branch names are deterministic (e.g. `ver-136-title`) and so + // collide when the same issue is launched again or a prior lane leaked the + // branch. Mirror the `ade/-` fallback: keep the clean name when + // it is free (so Linear's branch-name matching still works), otherwise append + // the lane's unique suffix until the name is unused. Never hard-fail here. + if (isLinearBranch) { + if (!(await branchIsTaken(branchRef))) return branchRef; + const compactId = args.laneId.replace(/-/g, ""); + for (let len = 6; len <= 12; len += 2) { + const candidate = sanitizeLinearIssueBranchName(`${branchRef}-${compactId.slice(0, len)}`); + if (!(await branchIsTaken(candidate))) return candidate; } + // Astronomically unlikely; fall back to the guaranteed-unique slug form. + return fallback; + } + + if (await branchIsTaken(branchRef)) { + throw new Error( + `Branch "${branchRef}" already exists locally or on the remote. Choose a different branch name.`, + ); } return branchRef; @@ -2041,52 +2224,57 @@ export function createLaneService({ timeoutMs: 60_000 }) ); - linkExistingDependencyInstalls(worktreePath); - const laneColor = allocateLaneColorForProject(); - db.run( - ` - insert into lanes( - id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path, - attached_root_path, is_edit_protected, parent_lane_id, color, icon, tags_json, folder, runtime_placement, status, created_at, archived_at - ) - values(?, ?, ?, ?, 'worktree', ?, ?, ?, null, 0, ?, ?, null, null, ?, ?, 'active', ?, null) - `, - [ - laneId, - projectId, - args.name, - args.description ?? null, - args.baseRef, - branchRef, - worktreePath, - args.parentLaneId, - laneColor, - args.folder ?? null, - runtimePlacement, - now - ] - ); - const linearIssue = args.linearIssue - ? upsertLaneLinearIssue(laneId, args.linearIssue, branchRef) - : null; - invalidateLaneListCache(); + // From this point the worktree exists on disk. Any failure persisting the lane + // row (or its dependent inserts / VM wiring) must remove the worktree, otherwise + // we orphan a checkout that no lane row references. + let linearIssue: LaneLinearIssue | null = null; + try { + linkExistingDependencyInstalls(worktreePath); - if (runtimePlacement === "macos-vm") { - try { + const laneColor = allocateLaneColorForProject(); + db.run( + ` + insert into lanes( + id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path, + attached_root_path, is_edit_protected, parent_lane_id, color, icon, tags_json, folder, runtime_placement, status, created_at, archived_at + ) + values(?, ?, ?, ?, 'worktree', ?, ?, ?, null, 0, ?, ?, null, null, ?, ?, 'active', ?, null) + `, + [ + laneId, + projectId, + args.name, + args.description ?? null, + args.baseRef, + branchRef, + worktreePath, + args.parentLaneId, + laneColor, + args.folder ?? null, + runtimePlacement, + now + ] + ); + linearIssue = args.linearIssue + ? upsertLaneLinearIssue(laneId, args.linearIssue, branchRef) + : null; + invalidateLaneListCache(); + + if (runtimePlacement === "macos-vm") { await wireMacosVmLanePlacement({ laneId, previousPlacement: "none", rollbackPlacementOnLinkFailure: true, }); - } catch (error) { - await cleanupCreatedWorktreeLaneAfterVmWireFailure({ - laneId, - branchRef, - worktreePath, - cause: error, - }); } + } catch (error) { + await cleanupCreatedWorktreeLaneAfterCreateFailure({ + laneId, + branchRef, + worktreePath, + cause: error, + }); } // Best-effort initial push to establish upstream tracking @@ -2341,7 +2529,7 @@ export function createLaneService({ db.run("delete from lanes where id = ? and project_id = ?", [laneId, projectId]); }; - async function cleanupCreatedWorktreeLaneAfterVmWireFailure(args: { + async function cleanupCreatedWorktreeLaneAfterCreateFailure(args: { laneId: string; branchRef: string; worktreePath: string; @@ -2387,14 +2575,14 @@ export function createLaneService({ } if (cleanupErrors.length > 0) { - logger.error("laneService.vm_lane_create_cleanup_failed", { + logger.error("laneService.lane_create_cleanup_failed", { laneId: args.laneId, branchRef: args.branchRef, worktreePath: args.worktreePath, error: originalMessage, cleanupErrors, }); - throw new Error(`${originalMessage} Cleanup after failed VM lane creation also failed: ${cleanupErrors.join("; ")}`); + throw new Error(`${originalMessage} Cleanup after failed lane creation also failed: ${cleanupErrors.join("; ")}`); } throw args.cause instanceof Error ? args.cause : new Error(originalMessage); @@ -2505,6 +2693,200 @@ export function createLaneService({ return links; }, + /** + * Attach one or more Linear issues to a chat or CLI session. Works for + * standalone sessions with no lane. When the session resolves to a lane, + * each issue is also mirrored into `lane_linear_issue_links` (source + * `chat_attach`, evidence.chatSessionId) so lane-level closeout/PR-open + * linking can fan out from the session. Mirrors `linkLinearIssues`: returns + * the session-scoped links it created, deduped by issue id, skipping issues + * that are missing required fields. + */ + attachLinearIssueToSession(args: { + chatSessionId: string; + issues: LaneLinearIssue[]; + role?: LaneLinearIssueLinkRole; + source?: LaneLinearIssueLinkSource; + includeInPr?: boolean; + closeOnMerge?: boolean; + evidence?: SessionLinearIssueLink["evidence"]; + }): SessionLinearIssueLink[] { + const chatSessionId = args.chatSessionId.trim(); + if (!chatSessionId) throw new Error("chatSessionId is required."); + const laneId = resolveSessionLaneId(chatSessionId); + const laneRow = laneId ? getLaneRow(laneId) : null; + const branchHint = laneRow?.branch_ref ?? null; + const role = args.role ?? "worked"; + const source = args.source ?? "chat_attach"; + const includeInPr = args.includeInPr ?? true; + const closeOnMerge = args.closeOnMerge ?? false; + const evidence = args.evidence ?? { chatSessionId }; + const links: SessionLinearIssueLink[] = []; + const seen = new Set(); + let mirrored = false; + for (const issue of args.issues) { + const normalized = finalizeLaneLinearIssue( + issue, + issue.branchName ?? branchHint ?? linearIssueBranchName(issue), + ); + if (!isLinkableLaneLinearIssue(normalized) || seen.has(normalized.id)) continue; + seen.add(normalized.id); + links.push(upsertSessionLinearIssueLink({ + sessionId: chatSessionId, + laneId, + issue: normalized, + role, + source, + includeInPr, + closeOnMerge, + evidence, + })); + // Mirror into the lane-scoped link table when the session has a lane so + // the lane's PR/closeout flows treat the issue as worked. Never promote + // to the lane's `primary` issue here — that is reserved for lane-create. + if (laneId && laneRow?.status !== "archived") { + try { + const primary = getLaneLinearIssue(laneId); + if (!primary || (primary.id !== normalized.id && primary.identifier !== normalized.identifier)) { + upsertLaneLinearIssueLink({ + laneId, + issue: normalized, + role: role === "primary" ? "worked" : role, + source: "chat_attach", + includeInPr, + closeOnMerge, + evidence: { chatSessionId }, + }); + mirrored = true; + } + } catch (error) { + logger.warn("laneService.session_link_lane_mirror_failed", { + chatSessionId, + laneId, + issueId: normalized.id, + error: error instanceof Error ? error.message : String(error), + }); + } + } + } + if (mirrored) invalidateLaneListCache(); + return links; + }, + + /** + * Detach Linear issues from a session. Omit `issueId` to detach every issue + * attached to the session. When the session resolves to a lane, the + * mirrored `chat_attach` lane links for the same issues are removed too. + * Returns true when at least one row was deleted. + */ + detachLinearIssueFromSession(args: { chatSessionId: string; issueId?: string }): boolean { + const chatSessionId = args.chatSessionId.trim(); + if (!chatSessionId) throw new Error("chatSessionId is required."); + const issueId = args.issueId?.trim() || null; + const before = getSessionLinearIssueLinks(chatSessionId); + const target = issueId + ? before.filter((link) => link.issue.id === issueId || link.issue.identifier === issueId) + : before; + if (!target.length) return false; + const laneId = target.find((link) => link.laneId)?.laneId ?? resolveSessionLaneId(chatSessionId); + db.run("begin"); + try { + for (const link of target) { + db.run( + "delete from session_linear_issues where project_id = ? and id = ?", + [projectId, link.id], + ); + if (laneId) { + db.run( + ` + delete from lane_linear_issue_links + where project_id = ? + and lane_id = ? + and issue_id = ? + and source = 'chat_attach' + `, + [projectId, laneId, link.issue.id], + ); + } + } + db.run("commit"); + } catch (err) { + try { db.run("rollback"); } catch { /* keep original detach error */ } + throw err; + } + if (laneId) invalidateLaneListCache(); + return true; + }, + + /** List Linear issues attached to a single chat/CLI session. */ + listLinearIssuesForSession(args: { chatSessionId: string }): SessionLinearIssueLink[] { + return getSessionLinearIssueLinks(args.chatSessionId); + }, + + /** + * List every session-scoped Linear issue link across all chat and CLI + * sessions that belong to a lane. Used on PR-open to fan out session → + * lane → Linear attachment for issues that were only attached to a session. + */ + listLinearIssuesForLaneSessions(args: { laneId: string }): SessionLinearIssueLink[] { + const id = args.laneId.trim(); + if (!id) return []; + try { + return db.all( + ` + select * + from session_linear_issues + where project_id = ? + and lane_id = ? + order by + case role when 'primary' then 0 when 'worked' then 1 when 'referenced' then 2 else 3 end, + updated_at desc + `, + [projectId, id], + ).map(parseSessionLinearIssueLink).filter((link): link is SessionLinearIssueLink => Boolean(link)); + } catch { + return []; + } + }, + + /** + * Remove lane-scoped Linear issue links from a lane. Omit `issueId` to + * remove every non-primary link. NEVER touches the lane's primary issue + * (stored separately in `lane_linear_issues`); a link row that matches the + * primary is skipped defensively. Returns true when at least one row was + * deleted. Counterpart to `linkLinearIssues` for lane-level detach. + */ + unlinkLinearIssues(args: { laneId: string; issueId?: string }): boolean { + const laneId = args.laneId.trim(); + if (!laneId) throw new Error("laneId is required."); + const issueId = args.issueId?.trim() || null; + const existing = getLaneLinearIssueLinks(laneId); + const primary = getLaneLinearIssue(laneId); + const isPrimaryIssue = (link: LaneLinearIssueLink): boolean => + Boolean(primary) && (link.issue.id === primary!.id || link.issue.identifier === primary!.identifier); + const target = existing.filter((link) => { + if (isPrimaryIssue(link)) return false; + if (!issueId) return true; + return link.issue.id === issueId || link.issue.identifier === issueId; + }); + if (!target.length) return false; + db.run("begin"); + try { + for (const link of target) { + db.run( + "delete from lane_linear_issue_links where project_id = ? and id = ?", + [projectId, link.id], + ); + } + db.run("commit"); + } catch (err) { + try { db.run("rollback"); } catch { /* keep original unlink error */ } + throw err; + } + invalidateLaneListCache(); + return true; + }, + async create({ name, description, parentLaneId, baseBranch, branchName, startPoint, linearIssue, runtimePlacement }: CreateLaneArgs): Promise { const requestedStartPoint = startPoint?.trim() ?? ""; if (parentLaneId) { @@ -4055,6 +4437,7 @@ export function createLaneService({ laneId, deleteBranch = true, deleteRemoteBranch = false, + requireRemoteBranchDelete = false, remoteName = "origin", force = false } = args; @@ -4263,33 +4646,39 @@ export function createLaneService({ if (deleteBranch && row.branch_ref) { await runStep("git_branch_delete", async () => { - const refCheck = await runGit(["show-ref", "--verify", "--quiet", `refs/heads/${row.branch_ref}`], { + const branchName = branchNameForDelete(row.branch_ref, remoteName); + const refCheck = await runGit(["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`], { cwd: projectRoot, timeoutMs: 8_000, }); if (refCheck.exitCode !== 0) return { detail: "ref not found" }; - await runGitOrThrow(["branch", "-D", row.branch_ref], { cwd: projectRoot, timeoutMs: 30_000 }); - return { detail: row.branch_ref }; - }, { fatal: false }); + await runGitOrThrow(["branch", "-D", branchName], { cwd: projectRoot, timeoutMs: 30_000 }); + return { detail: branchName }; + }, { fatal: requireRemoteBranchDelete }); } if (deleteRemoteBranch && row.branch_ref) { await runStep("git_remote_branch_delete", async () => { const remote = remoteName.trim() || "origin"; + const branchName = branchNameForDelete(row.branch_ref, remote); const remoteCheck = await runGit(["remote", "get-url", remote], { cwd: projectRoot, timeoutMs: 8_000 }); if (remoteCheck.exitCode !== 0) { throw new Error(`Remote '${remote}' is not configured for this repository`); } - const remoteRefCheck = await runGit(["ls-remote", "--heads", remote, row.branch_ref], { + const remoteRefCheck = await runGit(["ls-remote", "--heads", remote, branchName], { cwd: projectRoot, timeoutMs: 30_000, }); - if (remoteRefCheck.exitCode !== 0 || remoteRefCheck.stdout.trim().length === 0) { - return { detail: "remote branch not found" }; + if (remoteRefCheck.exitCode !== 0) { + const detail = (remoteRefCheck.stderr || remoteRefCheck.stdout).trim(); + throw new Error(detail || `Unable to check ${remote}/${branchName}`); + } + if (remoteRefCheck.stdout.trim().length === 0) { + return { detail: `${remote}/${branchName} not found` }; } - await runGitOrThrow(["push", remote, "--delete", row.branch_ref], { cwd: projectRoot, timeoutMs: 45_000 }); - return { detail: `${remote}/${row.branch_ref}` }; - }, { fatal: false }); + await runGitOrThrow(["push", remote, "--delete", branchName], { cwd: projectRoot, timeoutMs: 45_000 }); + return { detail: `${remote}/${branchName}` }; + }, { fatal: requireRemoteBranchDelete }); } await runStep("pack_dir_remove", async () => { @@ -4359,10 +4748,14 @@ export function createLaneService({ let hasUnpushedCommits = false; let unpushedCommitCount = 0; let remoteBranchExists = false; - if (row.branch_ref) { + // Normalize the same way delete() does, so the risk the user confirms + // against matches the branch the delete actually touches (a remote-shaped + // ref like "origin/feature" must probe "feature"). + const riskBranchName = row.branch_ref ? branchNameForDelete(row.branch_ref, "origin") : ""; + if (riskBranchName) { const cwd = worktreeExists ? row.worktree_path : projectRoot; const unpushed = await runGit( - ["rev-list", "--count", row.branch_ref, "--not", "--remotes"], + ["rev-list", "--count", riskBranchName, "--not", "--remotes"], { cwd, timeoutMs: 8_000 } ); if (unpushed.exitCode === 0) { @@ -4370,7 +4763,7 @@ export function createLaneService({ hasUnpushedCommits = unpushedCommitCount > 0; } const remoteCheck = await runGit( - ["ls-remote", "--heads", "origin", row.branch_ref], + ["ls-remote", "--heads", "origin", riskBranchName], { cwd: projectRoot, timeoutMs: 8_000 } ); remoteBranchExists = remoteCheck.exitCode === 0 && remoteCheck.stdout.trim().length > 0; diff --git a/apps/desktop/src/main/services/prs/prService.ts b/apps/desktop/src/main/services/prs/prService.ts index c599c5290..38608af30 100644 --- a/apps/desktop/src/main/services/prs/prService.ts +++ b/apps/desktop/src/main/services/prs/prService.ts @@ -66,6 +66,7 @@ import type { PrStatus, PrSummary, PrSnapshotHydration, + SessionLinearIssueLink, PrWithConflicts, PrActionCapabilities, PrCreateCapabilities, @@ -139,6 +140,7 @@ import type { createConflictService } from "../conflicts/conflictService"; import type { createAgentChatService } from "../chat/agentChatService"; import type { LaneWorktreeLockService } from "../lanes/laneWorktreeLockService"; import type { IssueTracker } from "../cto/issueTracker"; +import type { LinearLiveStatusService } from "../cto/linearLiveStatusService"; import { publishLinearPrCard } from "../cto/linearLaneCardService"; import { spawn } from "node:child_process"; import { runGit, runGitMergeTree, runGitOrThrow } from "../git/git"; @@ -907,6 +909,7 @@ export function createPrService({ autoRebaseService, rebaseSuggestionService, getLinearIssueTracker, + getLinearLiveStatusService, openExternal, onHotRefreshChanged, }: { @@ -924,6 +927,7 @@ export function createPrService({ autoRebaseService?: ReturnType | null; rebaseSuggestionService?: ReturnType | null; getLinearIssueTracker?: () => IssueTracker | null; + getLinearLiveStatusService?: () => LinearLiveStatusService | null; openExternal: (url: string) => Promise; onHotRefreshChanged?: () => void; }) { @@ -955,6 +959,27 @@ export function createPrService({ laneWorktreeLockService.release({ token }); }; + // Session-scoped Linear links (chat + CLI sessions in the lane) so an issue + // attached only to a session — not the lane itself — still gets a PR + // attachment on open. Mirrors source-of-truth in `lane_linear_issue_links` + // where available, but `session_linear_issues` is authoritative for sessions + // whose lane mirror never landed (e.g. standalone CLI attach after snapshot). + const collectLinearPrIssueReferencesForLaneSessions = (laneId: string): LinearPrIssueReference[] => { + let links: SessionLinearIssueLink[] = []; + try { + links = laneService.listLinearIssuesForLaneSessions?.({ laneId }) ?? []; + } catch (error) { + logger.warn("prs.linear_session_links_read_failed", { + laneId, + error: error instanceof Error ? error.message : String(error), + }); + return []; + } + return links + .filter((link) => link.includeInPr) + .map((link) => ({ issue: link.issue, closeOnMerge: link.closeOnMerge, role: link.role })); + }; + const publishLinearPrCardsForLane = async (args: { lane: LaneSummary; repo: GitHubRepoRef; @@ -965,7 +990,10 @@ export function createPrService({ }): Promise => { const issueTracker = getLinearIssueTracker?.() ?? null; if (!issueTracker) return; - const refs = collectLinearPrIssueReferences(args.lane, args.closePrimaryOnMerge); + const refs = dedupeLinearPrIssueReferences([ + ...collectLinearPrIssueReferences(args.lane, args.closePrimaryOnMerge), + ...collectLinearPrIssueReferencesForLaneSessions(args.lane.id), + ]); if (!refs.length) return; const results = await Promise.allSettled(refs.map((reference) => publishLinearPrCard({ @@ -989,6 +1017,23 @@ export function createPrService({ error: result.reason instanceof Error ? result.reason.message : String(result.reason), }); } + + // Live status round-trip (no-op unless the flag is set): comment the PR link + // back onto each linked issue. + const liveStatus = getLinearLiveStatusService?.() ?? null; + if (liveStatus?.enabled) { + await liveStatus.onPrOpened({ + issueIds: refs.map((reference) => reference.issue.id), + prNumber: args.prNumber, + githubUrl: args.githubUrl, + }).catch((error) => { + logger.warn("prs.linear_live_status_pr_failed", { + laneId: args.lane.id, + prNumber: args.prNumber, + error: error instanceof Error ? error.message : String(error), + }); + }); + } }; const getRowById = (prId: string): PullRequestRow | null => diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts index 4ea029092..758c0bb00 100644 --- a/apps/desktop/src/main/services/pty/ptyService.test.ts +++ b/apps/desktop/src/main/services/pty/ptyService.test.ts @@ -338,6 +338,8 @@ function createHarness(overrides: { baseRef: "origin/main", branchRef: "feature/test", })), + attachLinearIssueToSession: vi.fn((args: { chatSessionId: string; issues: unknown[] }) => + args.issues.map((issue) => ({ issue }))), }; const logger = { @@ -468,6 +470,40 @@ describe("ptyService", () => { expect(result.pid).toBe(12345); }); + it("attaches requested Linear issues to the new session before spawn (FIX 5)", async () => { + const { service, laneService } = createHarness(); + const issue = { id: "issue-1", identifier: "ADE-77" }; + const result = await service.create({ + laneId: "lane-1", + title: "Codex CLI", + cols: 80, + rows: 24, + tracked: true, + linearIssues: [issue as any], + }); + + // The attach runs against the freshly-created session id so the lane mirror + // lands and getSessionLinearEnv can resolve ADE_LINEAR_* for the spawn. + expect(laneService.attachLinearIssueToSession).toHaveBeenCalledWith( + expect.objectContaining({ + chatSessionId: result.sessionId, + issues: [issue], + }), + ); + }); + + it("does not attach Linear issues when none are requested", async () => { + const { service, laneService } = createHarness(); + await service.create({ + laneId: "lane-1", + title: "Plain shell", + cols: 80, + rows: 24, + tracked: true, + }); + expect(laneService.attachLinearIssueToSession).not.toHaveBeenCalled(); + }); + it("waits for a supervised PTY host spawn before returning", async () => { const { service, mockPty } = createHarness(); let resolveReady!: () => void; diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index f450e10b3..13e27189e 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -930,6 +930,7 @@ export function createPtyService({ aiIntegrationService, projectConfigService, getLaneRuntimeEnv, + getSessionLinearEnv, getAdeCliAgentEnv, logger, broadcastData, @@ -947,6 +948,13 @@ export function createPtyService({ aiIntegrationService?: ReturnType; projectConfigService?: ReturnType; getLaneRuntimeEnv?: (laneId: string) => Promise> | Record; + /** + * Per-session Linear context env (`ADE_LINEAR_ISSUE_IDS`, + * `ADE_LINEAR_CONTEXT_FILE`) for a CLI terminal agent, keyed by the session's + * chat/session id. Lets a CLI agent read its attached Linear issues without + * Linear creds, mirroring the SDK chat path's `buildAgentRuntimeEnv`. + */ + getSessionLinearEnv?: (args: { sessionId: string; chatSessionId: string | null }) => Record | null; getAdeCliAgentEnv?: (baseEnv?: NodeJS.ProcessEnv) => NodeJS.ProcessEnv; logger: Logger; broadcastData: (ev: PtyDataEvent) => void; @@ -3379,6 +3387,30 @@ export function createPtyService({ }); setRuntimeState(sessionId, "running"); + // Attach any requested Linear issues to the freshly-created session row + // BEFORE env is built below, so getSessionLinearEnv resolves them and the + // spawned CLI agent inherits ADE_LINEAR_* (and the lane-mirror link lands + // now that the terminal row exists). Best-effort: never block the spawn. + if (Array.isArray(args.linearIssues) && args.linearIssues.length) { + try { + laneService.attachLinearIssueToSession?.({ + chatSessionId: sessionId, + issues: args.linearIssues, + role: "worked", + source: "chat_attach", + includeInPr: true, + closeOnMerge: false, + evidence: { chatSessionId: sessionId }, + }); + } catch (error) { + logger.warn("pty.session_linear_attach_failed", { + sessionId, + issueCount: args.linearIssues.length, + error: error instanceof Error ? error.message : String(error), + }); + } + } + // Best-effort head SHA at start; do not block terminal creation. Promise.resolve() .then(async () => { @@ -3393,11 +3425,13 @@ export function createPtyService({ const directArgs = Array.isArray(args.args) ? args.args.filter((value): value is string => typeof value === "string") : []; const laneRuntimeEnv = (await getLaneRuntimeEnv?.(laneId)) ?? {}; + const sessionLinearEnv = getSessionLinearEnv?.({ sessionId, chatSessionId }) ?? {}; const explicitNoColor = hasEnvKey(args.env ?? {}, "NO_COLOR") || hasEnvKey(laneRuntimeEnv, "NO_COLOR"); const explicitForceColor = hasEnvKey(args.env ?? {}, "FORCE_COLOR") || hasEnvKey(laneRuntimeEnv, "FORCE_COLOR"); const baseLaunchEnv = { ...process.env, ...laneRuntimeEnv, + ...sessionLinearEnv, ...(args.env ?? {}) }; if (explicitNoColor && !explicitForceColor) { diff --git a/apps/desktop/src/main/services/state/kvDb.test.ts b/apps/desktop/src/main/services/state/kvDb.test.ts index 8629b8ee9..3d3115594 100644 --- a/apps/desktop/src/main/services/state/kvDb.test.ts +++ b/apps/desktop/src/main/services/state/kvDb.test.ts @@ -233,6 +233,46 @@ describe("lane_linear_issue_links schema", () => { }); }); +describe("session_linear_issues schema", () => { + it("creates the table with session/lane/issue columns", async () => { + const projectRoot = makeProjectRoot("ade-kvdb-session-linear-cols-"); + const dbPath = path.join(projectRoot, ".ade", "ade.db"); + const db = await openKvDb(dbPath, createLogger() as any); + activeDisposers.push(async () => db.close()); + + const columns = db + .all<{ name: string }>("pragma table_info('session_linear_issues')") + .map((row) => row.name); + expect(columns).toEqual(expect.arrayContaining([ + "id", + "project_id", + "session_id", + "lane_id", + "issue_id", + "issue_json", + "role", + "source", + "include_in_pr", + "close_on_merge", + "evidence_json", + "created_at", + "updated_at", + ])); + }); + + it("carries no non-PK unique index that would block crsql_as_crr", async () => { + const projectRoot = makeProjectRoot("ade-kvdb-session-linear-index-"); + const dbPath = path.join(projectRoot, ".ade", "ade.db"); + const db = await openKvDb(dbPath, createLogger() as any); + activeDisposers.push(async () => db.close()); + + const uniqueIndexes = db.all<{ name: string }>( + "select name from sqlite_master where type = 'index' and tbl_name = 'session_linear_issues' and sql like '%unique%'", + ); + expect(uniqueIndexes).toHaveLength(0); + }); +}); + describe.skipIf(!isCrsqliteAvailable())("openKvDb CRR repair", () => { it("enables CRR on lane_linear_issue_links without a blocking unique index", async () => { const projectRoot = makeProjectRoot("ade-kvdb-linear-issue-links-crr-"); @@ -248,6 +288,20 @@ describe.skipIf(!isCrsqliteAvailable())("openKvDb CRR repair", () => { ).toBe(1); }); + it("enables CRR on session_linear_issues without a blocking unique index", async () => { + const projectRoot = makeProjectRoot("ade-kvdb-session-linear-crr-"); + const dbPath = path.join(projectRoot, ".ade", "ade.db"); + const db = await openKvDb(dbPath, createLogger() as any); + activeDisposers.push(async () => db.close()); + + expect(() => db.get("select crsql_as_crr(?) as ok", ["session_linear_issues"])).not.toThrow(); + expect( + db.get<{ present: number }>( + "select 1 as present from sqlite_master where type = 'table' and name = 'session_linear_issues__crsql_clock' limit 1", + )?.present, + ).toBe(1); + }); + it("keeps composite-key PR AI summary cache local-only", async () => { const projectRoot = makeProjectRoot("ade-kvdb-ai-summary-local-"); const dbPath = path.join(projectRoot, ".ade", "ade.db"); diff --git a/apps/desktop/src/main/services/state/kvDb.ts b/apps/desktop/src/main/services/state/kvDb.ts index f50ca5986..f6c9b462d 100644 --- a/apps/desktop/src/main/services/state/kvDb.ts +++ b/apps/desktop/src/main/services/state/kvDb.ts @@ -1248,6 +1248,62 @@ function migrate(db: MigrationDb) { // best-effort migration; duplicates will be coalesced on the next upsert. } + // Session-scoped Linear issue links. A chat (`claude_sessions`) or CLI + // session (`terminal_sessions`) can attach a Linear issue even when it has + // no lane (standalone chats, `ade chat` sessions). `session_id` is the chat + // session id / terminal session id; `lane_id` mirrors the session's lane + // when one exists (so PR-open linking can fan out from session → lane). The + // schema mirrors `lane_linear_issue_links` so the two share parse/clone + // helpers and the same app-layer uniqueness discipline. + db.run(` + create table if not exists session_linear_issues ( + id text primary key, + project_id text not null, + session_id text not null, + lane_id text, + issue_id text not null, + issue_json text not null, + role text not null, + source text not null, + include_in_pr integer not null default 1, + close_on_merge integer not null default 0, + evidence_json text, + created_at text not null, + updated_at text not null, + foreign key(project_id) references projects(id) on delete cascade + ) + `); + db.run("create index if not exists idx_session_linear_issues_session on session_linear_issues(project_id, session_id)"); + db.run("create index if not exists idx_session_linear_issues_lane on session_linear_issues(project_id, lane_id)"); + db.run("create index if not exists idx_session_linear_issues_issue on session_linear_issues(project_id, issue_id)"); + // CRR-converted tables cannot carry UNIQUE indices besides the primary key + // (`crsql_as_crr` rejects them with "Table … has unique indices besides the + // primary key. This is not allowed for CRRs"), so uniqueness on + // (project_id, session_id, issue_id, role) is enforced at the application + // layer inside `upsertSessionLinearIssueLink` (delete-then-insert in a + // transaction). Coalesce any duplicates older dev builds may have produced — + // keep the most recently updated row per tuple and delete the rest. + try { + db.run(` + delete from session_linear_issues + where rowid not in ( + select rowid from session_linear_issues as keep + where keep.id = ( + select id from session_linear_issues inner_p + where inner_p.project_id = keep.project_id + and inner_p.session_id = keep.session_id + and inner_p.issue_id = keep.issue_id + and inner_p.role = keep.role + order by inner_p.updated_at desc, + inner_p.id asc + limit 1 + ) + ) + `); + } catch { + // best-effort migration; duplicates will be coalesced on the next upsert. + } + db.run(` create table if not exists lane_branch_profiles ( id text primary key, diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 4e93b56ed..a1502f889 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -71,6 +71,9 @@ import type { AgentChatCodexOpenInCliArgs, AgentChatCodexOpenInCliResult, AgentChatCreateArgs, + AgentChatLaunchArgs, + AgentChatLaunchCliArgs, + AgentChatLaunchCliResult, AgentChatDeleteArgs, AgentChatSuggestLaneNameArgs, AgentChatEventEnvelope, @@ -396,6 +399,7 @@ import type { UpdateIntegrationProposalArgs, UpdatePrDescriptionArgs, ListOverlapsArgs, + LaneLinearIssue, LaneSummary, ImportBranchLaneArgs, MergeSimulationArgs, @@ -461,6 +465,7 @@ import type { SuggestResolverTargetArgs, SuggestResolverTargetResult, SessionDeltaSummary, + SessionLinearIssueLink, TerminalSessionChangedEvent, StackChainItem, StopTestRunArgs, @@ -1155,6 +1160,18 @@ declare global { onDeleteEvent: (cb: (ev: LaneDeleteEvent) => void) => () => void; getStackChain: (laneId: string) => Promise; getChildren: (laneId: string) => Promise; + attachLinearIssueToSession: (args: { + chatSessionId: string; + issues: LaneLinearIssue[]; + role?: string; + source?: string; + includeInPr?: boolean; + closeOnMerge?: boolean; + }) => Promise; + detachLinearIssueFromSession: (args: { chatSessionId: string; issueId?: string }) => Promise; + listLinearIssuesForSession: (args: { chatSessionId: string }) => Promise; + listLinearIssuesForLaneSessions: (args: { laneId: string }) => Promise; + unlinkLinearIssues: (args: { laneId: string; issueId?: string }) => Promise; rebaseStart: (args: RebaseStartArgs) => Promise; rebasePush: (args: RebasePushArgs) => Promise; rebaseRollback: (args: RebaseRollbackArgs) => Promise; @@ -1261,6 +1278,10 @@ declare global { args: AgentChatGetSummaryArgs, ) => Promise; create: (args: AgentChatCreateArgs) => Promise; + launch: (args: AgentChatLaunchArgs) => Promise; + launchCli: ( + args: AgentChatLaunchCliArgs, + ) => Promise; suggestLaneName: ( args: AgentChatSuggestLaneNameArgs, ) => Promise; diff --git a/apps/desktop/src/preload/preload.test.ts b/apps/desktop/src/preload/preload.test.ts index 30cc0524a..d57d12128 100644 --- a/apps/desktop/src/preload/preload.test.ts +++ b/apps/desktop/src/preload/preload.test.ts @@ -3030,6 +3030,72 @@ describe("preload OAuth bridge", () => { expect(invoke).not.toHaveBeenCalledWith(IPC.agentChatCodexOpenInCli, input); }); + it("routes CLI agent launches through a remote project runtime when bound", async () => { + const binding = { + kind: "remote", + key: "remote:target-1:project-1", + targetId: "target-1", + runtimeName: "Remote", + projectId: "project-1", + rootPath: "/remote/project", + displayName: "Project", + }; + const input = { + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + kickoffPrompt: "Work this issue", + linearIssues: [{ id: "issue-1" }], + }; + const result = { + sessionId: "term-1", + ptyId: "pty-1", + pid: 123, + attachedLinearIssueIds: ["issue-1"], + }; + const invoke = vi.fn(async (channel: string) => { + if (channel === IPC.appGetWindowSession) { + return { windowId: 1, project: null, binding }; + } + if (channel === IPC.remoteRuntimeCallAction) { + return { ok: true, domain: "chat", action: "launchCli", result, statusHints: {} }; + } + return undefined; + }); + const on = vi.fn(); + const removeListener = vi.fn(); + const exposeInMainWorld = vi.fn((name: string, value: unknown) => { + (globalThis as any).__bridgeName = name; + (globalThis as any).__adeBridge = value; + }); + + vi.doMock("electron", () => ({ + contextBridge: { exposeInMainWorld }, + ipcRenderer: { invoke, on, removeListener }, + webFrame: { + getZoomLevel: vi.fn(() => 0), + setZoomLevel: vi.fn(), + getZoomFactor: vi.fn(() => 1), + }, + })); + + await import("./preload"); + + const bridge = (globalThis as any).__adeBridge; + await expect(bridge.agentChat.launchCli(input)).resolves.toEqual(result); + + expect(invoke).toHaveBeenCalledWith(IPC.remoteRuntimeCallAction, { + id: "target-1", + projectId: "project-1", + request: { + domain: "chat", + action: "launchCli", + args: input, + }, + }); + expect(invoke).not.toHaveBeenCalledWith(IPC.agentChatLaunchCli, input); + }); + it("does not fall back to a local PR lookup when remote open-in-GitHub misses", async () => { const binding = { kind: "remote", diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 0dba33f02..3969b8135 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -283,6 +283,9 @@ import type { AgentChatApproveArgs, AgentChatArchiveArgs, AgentChatCreateArgs, + AgentChatLaunchArgs, + AgentChatLaunchCliArgs, + AgentChatLaunchCliResult, AgentChatCodexOpenInCliArgs, AgentChatCodexOpenInCliResult, AgentChatDeleteArgs, @@ -348,8 +351,10 @@ import type { OnboardingStatus, OnboardingTourProgress, OnboardingTourVariant, + LaneLinearIssue, LaneListSnapshot, LaneSummary, + SessionLinearIssueLink, ListOverlapsArgs, ListLanesArgs, ImportBranchLaneArgs, @@ -1147,6 +1152,8 @@ const MUTATING_CHAT_ACTIONS = new Set([ "deleteSession", "updateSession", "handoffSession", + "launchCli", + "launchHeadless", "setClaudeOutputStyle", "reloadClaudePlugins", "setParallelLaunchState", @@ -4236,6 +4243,33 @@ contextBridge.exposeInMainWorld("ade", { callProjectRuntimeActionOr("lane", "getChildren", { arg: laneId }, () => ipcRenderer.invoke(IPC.lanesGetChildren, { laneId }), ), + attachLinearIssueToSession: async (args: { + chatSessionId: string; + issues: LaneLinearIssue[]; + role?: string; + source?: string; + includeInPr?: boolean; + closeOnMerge?: boolean; + }): Promise => + callProjectRuntimeActionOr("lane", "attachLinearIssueToSession", { args }, () => + ipcRenderer.invoke(IPC.lanesAttachLinearIssueToSession, args), + ), + detachLinearIssueFromSession: async (args: { chatSessionId: string; issueId?: string }): Promise => + callProjectRuntimeActionOr("lane", "detachLinearIssueFromSession", { args }, () => + ipcRenderer.invoke(IPC.lanesDetachLinearIssueFromSession, args), + ), + listLinearIssuesForSession: async (args: { chatSessionId: string }): Promise => + callProjectRuntimeActionOr("lane", "listLinearIssuesForSession", { args }, () => + ipcRenderer.invoke(IPC.lanesListLinearIssuesForSession, args), + ), + listLinearIssuesForLaneSessions: async (args: { laneId: string }): Promise => + callProjectRuntimeActionOr("lane", "listLinearIssuesForLaneSessions", { args }, () => + ipcRenderer.invoke(IPC.lanesListLinearIssuesForLaneSessions, args), + ), + unlinkLinearIssues: async (args: { laneId: string; issueId?: string }): Promise => + callProjectRuntimeActionOr("lane", "unlinkLinearIssues", { args }, () => + ipcRenderer.invoke(IPC.lanesUnlinkLinearIssues, args), + ), rebaseStart: async (args: RebaseStartArgs): Promise => callProjectRuntimeActionOr("lane", "rebaseStart", { args }, () => ipcRenderer.invoke(IPC.lanesRebaseStart, args), @@ -4730,6 +4764,35 @@ contextBridge.exposeInMainWorld("ade", { agentChatSummaryCache.clear(); return session as AgentChatSession; }, + launch: async (args: AgentChatLaunchArgs): Promise => { + agentChatSummaryCache.clear(); + const runtime = await callProjectRuntimeActionIfBound( + "chat", + "launchHeadless", + { args }, + ); + const session = runtime.handled + ? runtime.result + : await ipcRenderer.invoke(IPC.agentChatLaunch, args); + agentChatSummaryCache.clear(); + return session as AgentChatSession; + }, + launchCli: async ( + args: AgentChatLaunchCliArgs, + ): Promise => { + agentChatSummaryCache.clear(); + const runtime = + await callProjectRuntimeActionIfBound( + "chat", + "launchCli", + { args }, + ); + const result = runtime.handled + ? runtime.result + : await ipcRenderer.invoke(IPC.agentChatLaunchCli, args); + agentChatSummaryCache.clear(); + return result as AgentChatLaunchCliResult; + }, suggestLaneName: async ( args: AgentChatSuggestLaneNameArgs, ): Promise => diff --git a/apps/desktop/src/renderer/components/app/BatchLaunchModal.tsx b/apps/desktop/src/renderer/components/app/BatchLaunchModal.tsx new file mode 100644 index 000000000..a573904be --- /dev/null +++ b/apps/desktop/src/renderer/components/app/BatchLaunchModal.tsx @@ -0,0 +1,735 @@ +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { CaretDown, CaretRight, ChatCircleDots, GitBranch, Rocket, Terminal, WarningCircle } from "@phosphor-icons/react"; +import * as Popover from "@radix-ui/react-popover"; +import type { AgentChatPermissionMode, LaneLinearIssue, LaneSummary } from "../../../shared/types"; +import { linearIssueBranchName } from "../../../shared/linearIssueBranch"; +import { + getDefaultModelDescriptor, + getModelById, + resolveProviderGroupForModel, + type ModelDescriptor, +} from "../../../shared/modelRegistry"; +import { + batchLaunchSupportsFastMode, + defaultKickoffPrompt, + findIssueConflicts, + type BatchLaunchIssueConfig, + type BatchLaunchSessionType, +} from "../../lib/linearBatchLaunch"; +import { LaneDialogShell } from "../lanes/LaneDialogShell"; +import { LinearPriorityIcon, LinearStateIcon, LINEAR_BRAND } from "../lanes/linearBrand"; +import { LaneCombobox } from "../terminals/LaneCombobox"; +import { ModelPicker } from "../shared/ModelPicker/ModelPicker"; +import { ReasoningEffortPicker } from "../shared/ModelPicker/ReasoningEffortPicker"; +import { resolveModelDescriptorWithRuntimeCatalog } from "../shared/ModelPicker/modelCatalog"; +import { useModelRecents } from "../shared/ModelPicker/useModelRecents"; +import { useReasoningByFamily } from "../shared/ModelPicker/useReasoningByFamily"; +import { getPermissionOptions, safetyColors, type PermissionOption } from "../shared/permissionOptions"; +import { Button } from "../ui/Button"; +import { cn } from "../ui/cn"; + +function resolveModelDescriptor(modelId: string): ModelDescriptor | undefined { + return resolveModelDescriptorWithRuntimeCatalog(modelId) ?? getModelById(modelId); +} + +/** + * Permission options narrowed to the choices the launch surface exposes + * (mirrors the old single-issue resolver). Keeps Claude/Codex provider-aware + * while emitting a single unified `permissionMode`. + */ +function launchPermissionOptions(modelId: string): PermissionOption[] { + const descriptor = resolveModelDescriptor(modelId); + const family = descriptor?.family ?? "opencode"; + const providerGroup = descriptor ? resolveProviderGroupForModel(descriptor) : "opencode"; + const options = getPermissionOptions({ family, isCliWrapped: descriptor?.isCliWrapped ?? false }); + if (providerGroup === "codex") { + return options.filter((o) => o.value === "default" || o.value === "plan" || o.value === "full-auto"); + } + return options.filter( + (o) => o.value === "default" || o.value === "edit" || o.value === "plan" || o.value === "full-auto", + ); +} + +/** + * Compact permission dropdown — same popover style as the model/reasoning + * pickers so the launch row stays consistent with the new-chat composer. + */ +function PermissionPicker({ + modelId, + value, + onChange, +}: { + modelId: string; + value: AgentChatPermissionMode | null; + onChange: (next: AgentChatPermissionMode) => void; +}) { + const [open, setOpen] = useState(false); + const options = launchPermissionOptions(modelId); + if (!options.length) return null; + const active = (value && options.find((o) => o.value === value)) ?? options[0]!; + const activeColors = safetyColors(active.safety); + return ( + + + + + + event.preventDefault()} + > +
+
+ Permission +
+ {options.map((option) => { + const colors = safetyColors(option.safety); + const isActive = active.value === option.value; + return ( + + ); + })} +
+
+
+
+ ); +} + +type PerIssueState = BatchLaunchIssueConfig & { + /** When false the issue is excluded from the launch (skipped via the conflict guard). */ + include: boolean; + /** "new" creates a lane per issue; "existing" launches into `existingLaneId`. */ + laneTarget: "new" | "existing"; +}; + +const DEFAULT_PROMPT_STORAGE_PREFIX = "ade.linear.batchLaunch.defaultPrompt.v1:"; + +function defaultPromptStorageKey(projectRoot: string | null | undefined): string | null { + const root = projectRoot?.trim(); + return root ? `${DEFAULT_PROMPT_STORAGE_PREFIX}${root}` : null; +} + +function safeLoadDefaultPrompt(projectRoot: string | null | undefined): string | null { + const key = defaultPromptStorageKey(projectRoot); + if (!key || typeof window === "undefined") return null; + try { + const value = window.localStorage.getItem(key); + return value && value.trim().length > 0 ? value : null; + } catch { + return null; + } +} + +function safeSaveDefaultPrompt(projectRoot: string | null | undefined, prompt: string): void { + const key = defaultPromptStorageKey(projectRoot); + if (!key || typeof window === "undefined") return; + try { + const value = prompt.trim(); + if (!value) { + window.localStorage.removeItem(key); + return; + } + window.localStorage.setItem(key, prompt); + } catch { + // Best effort only; failing to persist a prompt should never block launch. + } +} + +function makeInitialConfig(defaultModelId: string, kickoffPrompt: string): PerIssueState { + return { + modelId: defaultModelId, + reasoningEffort: null, + codexFastMode: false, + // Seed the kickoff prompt with the default so the textarea is editable + // in-place (rather than only showing it as a placeholder). + kickoffPrompt, + branchOverride: "", + sessionType: "chat", + permissionMode: null, + existingLaneId: null, + include: true, + laneTarget: "new", + }; +} + +/** Compact two-option Chat/CLI toggle reused by the Default row and per-issue rows. */ +function SessionTypeToggle({ + value, + onChange, +}: { + value: BatchLaunchSessionType; + onChange: (next: BatchLaunchSessionType) => void; +}) { + return ( +
+ {([ + { key: "chat", label: "Chat", Icon: ChatCircleDots }, + { key: "cli", label: "CLI", Icon: Terminal }, + ] as const).map(({ key, label, Icon }) => { + const active = value === key; + return ( + + ); + })} +
+ ); +} + +export type BatchLaunchSubmit = { + issue: LaneLinearIssue; + config: BatchLaunchIssueConfig; +}; + +export function BatchLaunchModal({ + open, + projectRoot, + issues, + lanes, + laneOnly = false, + onOpenChange, + onLaunch, +}: { + open: boolean; + projectRoot?: string | null; + issues: LaneLinearIssue[]; + lanes: LaneSummary[]; + /** When true, only create lanes (no agent kickoff) — hides the model pickers. */ + laneOnly?: boolean; + onOpenChange: (open: boolean) => void; + /** Fires once, synchronously closing the modal; the orchestrator runs after. */ + onLaunch: (entries: BatchLaunchSubmit[]) => void; +}) { + const { recents } = useModelRecents(); + const defaultModelId = useMemo( + () => + recents[0] + ?? getDefaultModelDescriptor("claude")?.id + ?? getDefaultModelDescriptor("opencode")?.id + ?? "", + [recents], + ); + + const [defaultModel, setDefaultModel] = useState(""); + const [defaultEffort, setDefaultEffort] = useState(null); + const [defaultFast, setDefaultFast] = useState(false); + const [defaultSessionType, setDefaultSessionType] = useState("chat"); + const [defaultPermission, setDefaultPermission] = useState(null); + const [projectDefaultPrompt, setProjectDefaultPrompt] = useState(null); + const [perIssue, setPerIssue] = useState>({}); + const [expanded, setExpanded] = useState>({}); + + const conflicts = useMemo(() => findIssueConflicts(issues, lanes), [issues, lanes]); + // Existing-lane targets exclude the primary lane (parity with the old resolver). + const selectableLanes = useMemo( + () => lanes.filter((lane) => lane.laneType !== "primary"), + [lanes], + ); + + // Seed config when the modal opens (or the issue set changes while open). + // New rows inherit the LIVE Default row (model / Chat↔CLI / reasoning / fast / + // permission), not hardcoded chat defaults — otherwise reopening (which clears + // perIssue below) would silently launch rows that disagree with the Default + // control the user still sees. + useEffect(() => { + if (!open) return; + const savedPrompt = safeLoadDefaultPrompt(projectRoot); + const kickoffPrompt = savedPrompt ?? defaultKickoffPrompt(); + const seedModel = defaultModel || defaultModelId; + setProjectDefaultPrompt(savedPrompt); + setDefaultModel(seedModel); + setPerIssue((current) => { + const next: Record = {}; + for (const issue of issues) { + next[issue.id] = current[issue.id] ?? { + ...makeInitialConfig(seedModel, kickoffPrompt), + sessionType: defaultSessionType, + reasoningEffort: defaultEffort, + codexFastMode: defaultFast, + permissionMode: defaultPermission, + }; + } + return next; + }); + }, [ + open, + projectRoot, + issues, + defaultModelId, + defaultModel, + defaultSessionType, + defaultEffort, + defaultFast, + defaultPermission, + ]); + + useEffect(() => { + if (open) return; + setPerIssue({}); + setExpanded({}); + }, [open]); + + const patchIssue = useCallback((issueId: string, patch: Partial) => { + setPerIssue((current) => ({ + ...current, + [issueId]: { ...current[issueId], ...patch }, + })); + }, []); + + const { getReasoningForFamily } = useReasoningByFamily(); + + // Resolve the reasoning effort the picker is actually DISPLAYING for a row. + // The shared ReasoningEffortPicker shows a family-remembered default when the + // explicit value is null, so the submitted value must match what the user sees + // (otherwise null is sent and the runtime falls back to "medium"). + const resolveDisplayedReasoning = useCallback( + (explicit: string | null, modelId: string): string | null => { + if (explicit) return explicit; + const desc = resolveModelDescriptorWithRuntimeCatalog(modelId) ?? getModelById(modelId); + return desc?.family ? getReasoningForFamily(desc.family) : null; + }, + [getReasoningForFamily], + ); + + // The Default row is a LIVE default (parity with the work-tab composer's single + // model/permission/chat-cli control): changing a default value immediately + // applies it to every issue row. Users can still override an individual row. + const applyDefaultField = useCallback( + (key: K, value: PerIssueState[K]) => { + setPerIssue((current) => { + let changed = false; + const next: Record = {}; + for (const [id, state] of Object.entries(current)) { + if (state[key] !== value) { + next[id] = { ...state, [key]: value }; + changed = true; + } else { + next[id] = state; + } + } + return changed ? next : current; + }); + }, + [], + ); + + const applyDefaultToAll = useCallback(() => { + if (!defaultModel.trim()) return; + setPerIssue((current) => { + const next: Record = {}; + for (const [id, state] of Object.entries(current)) { + next[id] = { + ...state, + modelId: defaultModel, + reasoningEffort: defaultEffort, + codexFastMode: defaultFast, + sessionType: defaultSessionType, + permissionMode: defaultPermission, + }; + } + return next; + }); + }, [defaultModel, defaultEffort, defaultFast, defaultSessionType, defaultPermission]); + + // Copy one issue's kickoff prompt onto every other included issue. + const applyPromptToAll = useCallback((sourcePrompt: string) => { + setPerIssue((current) => { + const next: Record = {}; + for (const [id, state] of Object.entries(current)) { + next[id] = { ...state, kickoffPrompt: sourcePrompt }; + } + return next; + }); + }, []); + + const savePromptAsDefault = useCallback((prompt: string) => { + safeSaveDefaultPrompt(projectRoot, prompt); + setProjectDefaultPrompt(prompt.trim() ? prompt : null); + }, [projectRoot]); + + const includedIssues = useMemo( + () => issues.filter((issue) => perIssue[issue.id]?.include !== false), + [issues, perIssue], + ); + + const handleLaunch = useCallback(() => { + const entries: BatchLaunchSubmit[] = []; + for (const issue of issues) { + const state = perIssue[issue.id]; + if (!state || state.include === false) continue; + if (!laneOnly && !state.modelId.trim()) continue; + const { include: _include, laneTarget, ...config } = state; + // An existing-lane target only takes effect when a lane is actually + // selected; otherwise fall back to creating a new lane. + const existingLaneId = + !laneOnly && laneTarget === "existing" ? state.existingLaneId?.trim() || null : null; + if (!laneOnly && laneTarget === "existing" && !existingLaneId) continue; + // Submit the reasoning the picker is actually DISPLAYING (it shows a + // family-remembered default when the explicit value is null), so the agent + // launches with the effort the user sees — not null → runtime "medium". + const reasoningEffort = resolveDisplayedReasoning(config.reasoningEffort, config.modelId); + entries.push({ issue, config: { ...config, reasoningEffort, existingLaneId, laneOnly } }); + } + if (!entries.length) return; + onLaunch(entries); + }, [issues, perIssue, onLaunch, laneOnly, resolveDisplayedReasoning]); + + if (!issues.length) return null; + + const conflictCount = includedIssues.filter((issue) => conflicts.has(issue.id)).length; + const launchCount = includedIssues.length; + + return ( + + {/* Default row */} + {!laneOnly ? ( +
+ Default + { setDefaultSessionType(next); applyDefaultField("sessionType", next); }} + /> + { setDefaultModel(next); applyDefaultField("modelId", next); }} + surfaceKey="batch-launch-default" + compact + fastModeActive={defaultFast} + onFastModeToggle={(next) => { setDefaultFast(next); applyDefaultField("codexFastMode", next); }} + fastModeSupported={batchLaunchSupportsFastMode(defaultModel)} + /> + { setDefaultEffort(next); applyDefaultField("reasoningEffort", next); }} + compact + /> + { setDefaultPermission(next); applyDefaultField("permissionMode", next); }} + /> + +
+ ) : null} + + {/* Per-issue rows */} +
+ {issues.map((issue) => { + const state = perIssue[issue.id]; + if (!state) return null; + const conflict = conflicts.get(issue.id); + const isExpanded = expanded[issue.id] === true; + const skipped = state.include === false; + const branch = state.branchOverride.trim() || linearIssueBranchName(issue); + const promptSavedAsDefault = + state.kickoffPrompt.trim().length > 0 && projectDefaultPrompt === state.kickoffPrompt; + return ( +
+
+
+ + + + + {issue.identifier} + + + {issue.title} + + {conflict ? ( + + + {conflict.reason === "session" ? "Has agent" : "Has lane"} + + ) : null} +
+ {!skipped && !laneOnly ? ( +
+ patchIssue(issue.id, { sessionType: next })} + /> + patchIssue(issue.id, { modelId })} + surfaceKey={`batch-launch-${issue.id}`} + compact + fastModeActive={state.codexFastMode} + onFastModeToggle={(next) => patchIssue(issue.id, { codexFastMode: next })} + fastModeSupported={batchLaunchSupportsFastMode(state.modelId)} + /> + patchIssue(issue.id, { reasoningEffort: effort })} + compact + /> + patchIssue(issue.id, { permissionMode: mode })} + /> +
+ ) : null} + {conflict ? ( + + ) : null} +
+ + {isExpanded && !skipped ? ( +
+ {!laneOnly ? ( +
+
+ + Kickoff prompt + +
+ + {issues.length > 1 ? ( + + ) : null} +
+
+