diff --git a/docs/cast-issue-create.svg b/docs/cast-issue-create.svg index 9ff98288..1ccc142e 100644 --- a/docs/cast-issue-create.svg +++ b/docs/cast-issue-create.svg @@ -4,15 +4,15 @@ width="1300" height="607.88" > - + - + + diff --git a/docs/cast-issue-start.svg b/docs/cast-issue-start.svg index 0c9d81a5..edc08372 100644 --- a/docs/cast-issue-start.svg +++ b/docs/cast-issue-start.svg @@ -4,15 +4,15 @@ width="1300" height="607.88" > - + - + + diff --git a/skills/linear-cli/references/issue.md b/skills/linear-cli/references/issue.md index 16f8bc03..1c1e3328 100644 --- a/skills/linear-cli/references/issue.md +++ b/skills/linear-cli/references/issue.md @@ -33,7 +33,8 @@ Commands: comment - Manage issue comments attach - Attach a file to an issue link [url] - Link a URL to an issue - relation - Manage issue relations (dependencies) + relation - Manage issue relations (dependencies) + agent-session - Manage agent sessions for an issue ``` ## Subcommands @@ -520,3 +521,60 @@ Options: -h, --help - Show this help. -w, --workspace - Target workspace (uses credentials) ``` + +### agent-session + +> Manage agent sessions for an issue + +``` +Usage: linear issue agent-session + +Description: + + Manage agent sessions for an issue + +Options: + + -h, --help - Show this help. + -w, --workspace - Target workspace (uses credentials) + +Commands: + + list [issueId] - List agent sessions for an issue + view, v - View agent session details +``` + +#### agent-session subcommands + +##### list + +``` +Usage: linear issue agent-session list [issueId] + +Description: + + List agent sessions for an issue + +Options: + + -h, --help - Show this help. + -w, --workspace - Target workspace (uses credentials) + -j, --json - Output as JSON + --status - Filter by status (pending, active, complete, awaitingInput, error, stale) +``` + +##### view + +``` +Usage: linear issue agent-session view + +Description: + + View agent session details + +Options: + + -h, --help - Show this help. + -w, --workspace - Target workspace (uses credentials) + -j, --json - Output as JSON +``` diff --git a/src/commands/issue/issue-agent-session-list.ts b/src/commands/issue/issue-agent-session-list.ts new file mode 100644 index 00000000..cb5fec78 --- /dev/null +++ b/src/commands/issue/issue-agent-session-list.ts @@ -0,0 +1,152 @@ +import { Command } from "@cliffy/command" +import { unicodeWidth } from "@std/cli" +import { green, yellow } from "@std/fmt/colors" +import { gql } from "../../__codegen__/gql.ts" +import { getGraphQLClient } from "../../utils/graphql.ts" +import { padDisplay, truncateText } from "../../utils/display.ts" +import { getIssueIdentifier } from "../../utils/linear.ts" +import { shouldShowSpinner } from "../../utils/hyperlink.ts" +import { header, muted } from "../../utils/styling.ts" +import { handleError, ValidationError } from "../../utils/errors.ts" + +const GetIssueAgentSessions = gql(` + query GetIssueAgentSessions($issueId: String!) { + issue(id: $issueId) { + comments(first: 100) { + nodes { + agentSession { + id + status + type + createdAt + startedAt + endedAt + summary + creator { + name + } + appUser { + name + } + } + } + } + } + } +`) + +function formatStatus(status: string): string { + switch (status) { + case "active": + return green(padDisplay("active", 13)) + case "pending": + return yellow(padDisplay("pending", 13)) + case "awaitingInput": + return yellow(padDisplay("awaitingInput", 13)) + case "complete": + return muted(padDisplay("complete", 13)) + case "error": + return padDisplay("error", 13) + case "stale": + return muted(padDisplay("stale", 13)) + default: + return padDisplay(status, 13) + } +} + +function formatDate(dateString: string): string { + return dateString.slice(0, 10) +} + +export const agentSessionListCommand = new Command() + .name("list") + .description("List agent sessions for an issue") + .arguments("[issueId:string]") + .option("-j, --json", "Output as JSON") + .option( + "--status ", + "Filter by status (pending, active, complete, awaitingInput, error, stale)", + ) + .action(async ({ json, status }, issueId) => { + try { + const resolvedIdentifier = await getIssueIdentifier(issueId) + if (!resolvedIdentifier) { + throw new ValidationError( + "Could not determine issue ID", + { suggestion: "Please provide an issue ID like 'ENG-123'." }, + ) + } + + const { Spinner } = await import("@std/cli/unstable-spinner") + const showSpinner = shouldShowSpinner() + const spinner = showSpinner ? new Spinner() : null + spinner?.start() + + const client = getGraphQLClient() + const result = await client.request(GetIssueAgentSessions, { + issueId: resolvedIdentifier, + }) + spinner?.stop() + + let sessions = (result.issue?.comments?.nodes || []) + .map((c) => c.agentSession) + .filter((s): s is NonNullable => s != null) + + if (status) { + sessions = sessions.filter((s) => s.status === status) + } + + if (json) { + console.log(JSON.stringify(sessions, null, 2)) + return + } + + if (sessions.length === 0) { + console.log("No agent sessions found for this issue.") + return + } + + const { columns } = Deno.stdout.isTerminal() + ? Deno.consoleSize() + : { columns: 120 } + + const STATUS_WIDTH = 13 + const DATE_WIDTH = 10 + const AGENT_WIDTH = Math.max( + 5, + ...sessions.map((s) => unicodeWidth(s.appUser.name)), + ) + const SPACE_WIDTH = 3 + + const fixed = STATUS_WIDTH + DATE_WIDTH + AGENT_WIDTH + SPACE_WIDTH + const PADDING = 1 + const availableWidth = Math.max(columns - PADDING - fixed, 10) + + const headerCells = [ + padDisplay("STATUS", STATUS_WIDTH), + padDisplay("AGENT", AGENT_WIDTH), + padDisplay("CREATED", DATE_WIDTH), + "SUMMARY", + ] + + console.log(header(headerCells.join(" "))) + + for (const session of sessions) { + const summaryText = session.summary + ? truncateText( + session.summary.replace(/\n/g, " "), + availableWidth, + ) + : muted("--") + + const line = `${formatStatus(session.status)} ${ + padDisplay(session.appUser.name, AGENT_WIDTH) + } ${ + padDisplay(formatDate(session.createdAt), DATE_WIDTH) + } ${summaryText}` + console.log(line) + } + } catch (error) { + handleError(error, "Failed to list agent sessions") + } + }) diff --git a/src/commands/issue/issue-agent-session-view.ts b/src/commands/issue/issue-agent-session-view.ts new file mode 100644 index 00000000..cb08784d --- /dev/null +++ b/src/commands/issue/issue-agent-session-view.ts @@ -0,0 +1,179 @@ +import { Command } from "@cliffy/command" +import { renderMarkdown } from "@littletof/charmd" +import { gql } from "../../__codegen__/gql.ts" +import { getGraphQLClient } from "../../utils/graphql.ts" +import { formatRelativeTime } from "../../utils/display.ts" +import { shouldShowSpinner } from "../../utils/hyperlink.ts" +import { handleError, NotFoundError } from "../../utils/errors.ts" + +const GetAgentSessionDetails = gql(` + query GetAgentSessionDetails($id: String!) { + agentSession(id: $id) { + id + status + type + createdAt + updatedAt + startedAt + endedAt + dismissedAt + summary + externalLink + creator { + name + } + appUser { + name + } + dismissedBy { + name + } + issue { + identifier + title + url + } + activities(first: 20) { + nodes { + id + createdAt + content { + ... on AgentActivityThoughtContent { + type + body + } + ... on AgentActivityActionContent { + type + action + parameter + result + } + ... on AgentActivityResponseContent { + type + body + } + ... on AgentActivityPromptContent { + type + body + } + ... on AgentActivityErrorContent { + type + body + } + ... on AgentActivityElicitationContent { + type + body + } + } + } + } + } + } +`) + +export const agentSessionViewCommand = new Command() + .name("view") + .description("View agent session details") + .alias("v") + .arguments("") + .option("-j, --json", "Output as JSON") + .action(async ({ json }, sessionId) => { + try { + const { Spinner } = await import("@std/cli/unstable-spinner") + const showSpinner = shouldShowSpinner() + const spinner = showSpinner ? new Spinner() : null + spinner?.start() + + const client = getGraphQLClient() + const result = await client.request(GetAgentSessionDetails, { + id: sessionId, + }) + spinner?.stop() + + const session = result.agentSession + if (!session) { + throw new NotFoundError("Agent session", sessionId) + } + + if (json) { + console.log(JSON.stringify(session, null, 2)) + return + } + + const lines: string[] = [] + + lines.push(`# Agent Session`) + lines.push("") + + lines.push(`**ID:** ${session.id}`) + lines.push(`**Status:** ${session.status}`) + lines.push(`**Type:** ${session.type}`) + lines.push(`**Agent:** ${session.appUser.name}`) + + if (session.creator) { + lines.push(`**Creator:** ${session.creator.name}`) + } + + if (session.issue) { + lines.push( + `**Issue:** ${session.issue.identifier} - ${session.issue.title}`, + ) + } + + lines.push("") + lines.push(`**Created:** ${formatRelativeTime(session.createdAt)}`) + if (session.startedAt) { + lines.push(`**Started:** ${formatRelativeTime(session.startedAt)}`) + } + if (session.endedAt) { + lines.push(`**Ended:** ${formatRelativeTime(session.endedAt)}`) + } + if (session.dismissedAt) { + lines.push(`**Dismissed:** ${formatRelativeTime(session.dismissedAt)}`) + if (session.dismissedBy) { + lines.push(`**Dismissed by:** ${session.dismissedBy.name}`) + } + } + + if (session.externalLink) { + lines.push("") + lines.push(`**External Link:** ${session.externalLink}`) + } + + if (session.summary) { + lines.push("") + lines.push("## Summary") + lines.push("") + lines.push(session.summary) + } + + if (session.activities.nodes.length > 0) { + lines.push("") + lines.push("## Activities") + lines.push("") + for (const activity of session.activities.nodes) { + const time = formatRelativeTime(activity.createdAt) + const content = activity.content + const type = "type" in content ? content.type : "unknown" + let detail = "" + if ("body" in content && content.body) { + detail = ` - ${content.body.replace(/\n/g, " ")}` + } else if ("action" in content && content.action) { + detail = ` - ${content.action}: ${content.parameter}` + } + lines.push(`- **${type}** (${time})${detail}`) + } + } + + const markdown = lines.join("\n") + + if (Deno.stdout.isTerminal()) { + const terminalWidth = Deno.consoleSize().columns + console.log(renderMarkdown(markdown, { lineWidth: terminalWidth })) + } else { + console.log(markdown) + } + } catch (error) { + handleError(error, "Failed to fetch agent session details") + } + }) diff --git a/src/commands/issue/issue-agent-session.ts b/src/commands/issue/issue-agent-session.ts new file mode 100644 index 00000000..914d58ab --- /dev/null +++ b/src/commands/issue/issue-agent-session.ts @@ -0,0 +1,11 @@ +import { Command } from "@cliffy/command" +import { agentSessionListCommand } from "./issue-agent-session-list.ts" +import { agentSessionViewCommand } from "./issue-agent-session-view.ts" + +export const agentSessionCommand = new Command() + .description("Manage agent sessions for an issue") + .action(function () { + this.showHelp() + }) + .command("list", agentSessionListCommand) + .command("view", agentSessionViewCommand) diff --git a/src/commands/issue/issue.ts b/src/commands/issue/issue.ts index 56b9f517..bce5ae38 100644 --- a/src/commands/issue/issue.ts +++ b/src/commands/issue/issue.ts @@ -10,6 +10,7 @@ import { linkCommand } from "./issue-link.ts" import { listCommand } from "./issue-list.ts" import { pullRequestCommand } from "./issue-pull-request.ts" import { relationCommand } from "./issue-relation.ts" +import { agentSessionCommand } from "./issue-agent-session.ts" import { startCommand } from "./issue-start.ts" import { titleCommand } from "./issue-title.ts" import { updateCommand } from "./issue-update.ts" @@ -37,3 +38,4 @@ export const issueCommand = new Command() .command("attach", attachCommand) .command("link", linkCommand) .command("relation", relationCommand) + .command("agent-session", agentSessionCommand) diff --git a/test/commands/issue/__snapshots__/issue-agent-session-list.test.ts.snap b/test/commands/issue/__snapshots__/issue-agent-session-list.test.ts.snap new file mode 100644 index 00000000..f5edbb02 --- /dev/null +++ b/test/commands/issue/__snapshots__/issue-agent-session-list.test.ts.snap @@ -0,0 +1,39 @@ +export const snapshot = {}; + +snapshot[`Issue Agent Session List Command - Help Text 1`] = ` +stdout: +" +Usage: list [issueId] + +Description: + + List agent sessions for an issue + +Options: + + -h, --help - Show this help. + -j, --json - Output as JSON + --status - Filter by status (pending, active, complete, awaitingInput, error, stale) + +" +stderr: +"" +`; + +snapshot[`Issue Agent Session List Command - With Mock Sessions 1`] = ` +stdout: +"STATUS AGENT CREATED SUMMARY +active Linear Assistant 2026-03-20 Investigating auth token refresh bug +complete Linear Assistant 2026-03-19 Added dark mode toggle to settings page +" +stderr: +"" +`; + +snapshot[`Issue Agent Session List Command - No Sessions Found 1`] = ` +stdout: +"No agent sessions found for this issue. +" +stderr: +"" +`; diff --git a/test/commands/issue/__snapshots__/issue-agent-session-view.test.ts.snap b/test/commands/issue/__snapshots__/issue-agent-session-view.test.ts.snap new file mode 100644 index 00000000..3f68e965 --- /dev/null +++ b/test/commands/issue/__snapshots__/issue-agent-session-view.test.ts.snap @@ -0,0 +1,73 @@ +export const snapshot = {}; + +snapshot[`Issue Agent Session View Command - Help Text 1`] = ` +stdout: +" +Usage: view + +Description: + + View agent session details + +Options: + + -h, --help - Show this help. + -j, --json - Output as JSON + +" +stderr: +"" +`; + +snapshot[`Issue Agent Session View Command - Active Session With Activities 1`] = ` +stdout: +"# Agent Session + +**ID:** session-1 +**Status:** active +**Type:** commentThread +**Agent:** Linear Assistant +**Creator:** Alice +**Issue:** ENG-412 - Fix auth token refresh + +**Created:** 1/1/2020 +**Started:** 1/1/2020 + +## Summary + +Investigating auth token refresh bug in the middleware layer + +## Activities + +- **thought** (1/1/2020) - Looking at the auth middleware code +- **action** (1/1/2020) - read_file: src/middleware/auth.ts +- **response** (1/1/2020) - The token refresh is failing because the expiry check uses UTC +" +stderr: +"" +`; + +snapshot[`Issue Agent Session View Command - Completed Session No Activities 1`] = ` +stdout: +"# Agent Session + +**ID:** session-2 +**Status:** complete +**Type:** commentThread +**Agent:** Linear Assistant +**Creator:** Bob +**Issue:** ENG-398 - Add dark mode toggle + +**Created:** 1/1/2020 +**Started:** 1/1/2020 +**Ended:** 1/1/2020 + +**External Link:** https://github.com/org/repo/pull/42 + +## Summary + +Added dark mode toggle to settings page +" +stderr: +"" +`; diff --git a/test/commands/issue/issue-agent-session-list.test.ts b/test/commands/issue/issue-agent-session-list.test.ts new file mode 100644 index 00000000..be76c63a --- /dev/null +++ b/test/commands/issue/issue-agent-session-list.test.ts @@ -0,0 +1,122 @@ +import { snapshotTest as cliffySnapshotTest } from "@cliffy/testing" +import { agentSessionListCommand } from "../../../src/commands/issue/issue-agent-session-list.ts" +import { commonDenoArgs } from "../../utils/test-helpers.ts" +import { MockLinearServer } from "../../utils/mock_linear_server.ts" + +await cliffySnapshotTest({ + name: "Issue Agent Session List Command - Help Text", + meta: import.meta, + colors: false, + args: ["--help"], + denoArgs: commonDenoArgs, + async fn() { + await agentSessionListCommand.parse() + }, +}) + +await cliffySnapshotTest({ + name: "Issue Agent Session List Command - With Mock Sessions", + meta: import.meta, + colors: false, + args: ["ENG-412"], + denoArgs: commonDenoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetIssueAgentSessions", + variables: { issueId: "ENG-412" }, + response: { + data: { + issue: { + comments: { + nodes: [ + { + agentSession: { + id: "session-1", + status: "active", + type: "commentThread", + createdAt: "2026-03-20T10:00:00.000Z", + startedAt: "2026-03-20T10:00:05.000Z", + endedAt: null, + summary: "Investigating auth token refresh bug", + creator: { name: "Alice" }, + appUser: { name: "Linear Assistant" }, + }, + }, + { + agentSession: { + id: "session-2", + status: "complete", + type: "commentThread", + createdAt: "2026-03-19T15:30:00.000Z", + startedAt: "2026-03-19T15:30:05.000Z", + endedAt: "2026-03-19T16:00:00.000Z", + summary: "Added dark mode toggle to settings page", + creator: { name: "Bob" }, + appUser: { name: "Linear Assistant" }, + }, + }, + { + agentSession: null, + }, + ], + }, + }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await agentSessionListCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +await cliffySnapshotTest({ + name: "Issue Agent Session List Command - No Sessions Found", + meta: import.meta, + colors: false, + args: ["ENG-412"], + denoArgs: commonDenoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetIssueAgentSessions", + variables: { issueId: "ENG-412" }, + response: { + data: { + issue: { + comments: { + nodes: [ + { agentSession: null }, + { agentSession: null }, + ], + }, + }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await agentSessionListCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) diff --git a/test/commands/issue/issue-agent-session-view.test.ts b/test/commands/issue/issue-agent-session-view.test.ts new file mode 100644 index 00000000..d6b4c2f1 --- /dev/null +++ b/test/commands/issue/issue-agent-session-view.test.ts @@ -0,0 +1,157 @@ +import { snapshotTest } from "@cliffy/testing" +import { agentSessionViewCommand } from "../../../src/commands/issue/issue-agent-session-view.ts" +import { commonDenoArgs } from "../../utils/test-helpers.ts" +import { MockLinearServer } from "../../utils/mock_linear_server.ts" + +await snapshotTest({ + name: "Issue Agent Session View Command - Help Text", + meta: import.meta, + colors: false, + args: ["--help"], + denoArgs: commonDenoArgs, + async fn() { + await agentSessionViewCommand.parse() + }, +}) + +await snapshotTest({ + name: "Issue Agent Session View Command - Active Session With Activities", + meta: import.meta, + colors: false, + args: ["session-1"], + denoArgs: commonDenoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetAgentSessionDetails", + variables: { id: "session-1" }, + response: { + data: { + agentSession: { + id: "session-1", + status: "active", + type: "commentThread", + createdAt: "2020-01-01T10:00:00Z", + updatedAt: "2020-01-01T10:05:00Z", + startedAt: "2020-01-01T10:00:05Z", + endedAt: null, + dismissedAt: null, + summary: + "Investigating auth token refresh bug in the middleware layer", + externalLink: null, + creator: { name: "Alice" }, + appUser: { name: "Linear Assistant" }, + dismissedBy: null, + issue: { + identifier: "ENG-412", + title: "Fix auth token refresh", + url: "https://linear.app/eng/issue/ENG-412", + }, + activities: { + nodes: [ + { + id: "activity-1", + createdAt: "2020-01-01T10:00:05Z", + content: { + __typename: "AgentActivityThoughtContent", + type: "thought", + body: "Looking at the auth middleware code", + }, + }, + { + id: "activity-2", + createdAt: "2020-01-01T10:01:00Z", + content: { + __typename: "AgentActivityActionContent", + type: "action", + action: "read_file", + parameter: "src/middleware/auth.ts", + result: "Found token refresh logic", + }, + }, + { + id: "activity-3", + createdAt: "2020-01-01T10:02:00Z", + content: { + __typename: "AgentActivityResponseContent", + type: "response", + body: + "The token refresh is failing because the expiry check uses UTC", + }, + }, + ], + }, + }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await agentSessionViewCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +await snapshotTest({ + name: "Issue Agent Session View Command - Completed Session No Activities", + meta: import.meta, + colors: false, + args: ["session-2"], + denoArgs: commonDenoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetAgentSessionDetails", + variables: { id: "session-2" }, + response: { + data: { + agentSession: { + id: "session-2", + status: "complete", + type: "commentThread", + createdAt: "2020-01-01T10:00:00Z", + updatedAt: "2020-01-01T10:30:00Z", + startedAt: "2020-01-01T10:00:05Z", + endedAt: "2020-01-01T10:30:00Z", + dismissedAt: null, + summary: "Added dark mode toggle to settings page", + externalLink: "https://github.com/org/repo/pull/42", + creator: { name: "Bob" }, + appUser: { name: "Linear Assistant" }, + dismissedBy: null, + issue: { + identifier: "ENG-398", + title: "Add dark mode toggle", + url: "https://linear.app/eng/issue/ENG-398", + }, + activities: { + nodes: [], + }, + }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await agentSessionViewCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +})