agent-dealer 0.0.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/bundle/server/dist/adapters/agent-deck.js +136 -0
  2. package/bundle/server/dist/adapters/agent-health.js +109 -0
  3. package/bundle/server/dist/adapters/external.js +21 -0
  4. package/bundle/server/dist/adapters/linear-inbox.js +112 -0
  5. package/bundle/server/dist/adapters/linear-sync.js +197 -0
  6. package/bundle/server/dist/cli-env.js +62 -0
  7. package/bundle/server/dist/config/load-env.js +78 -0
  8. package/bundle/server/dist/db/index.js +120 -0
  9. package/bundle/server/dist/db/migrate.js +6 -0
  10. package/bundle/server/dist/db/schema.sql +84 -0
  11. package/bundle/server/dist/index.js +30 -0
  12. package/bundle/server/dist/intake/agent-routing.js +32 -0
  13. package/bundle/server/dist/paths.js +16 -0
  14. package/bundle/server/dist/queue/dispatcher.js +305 -0
  15. package/bundle/server/dist/repository/agents.js +81 -0
  16. package/bundle/server/dist/repository/intake-settings.js +124 -0
  17. package/bundle/server/dist/repository/runs.js +421 -0
  18. package/bundle/server/dist/routes/index.js +552 -0
  19. package/bundle/server/dist/runners/claude.js +122 -0
  20. package/bundle/server/dist/runners/models.js +138 -0
  21. package/bundle/server/dist/runners/persist.js +100 -0
  22. package/bundle/server/dist/runners/prompts.js +164 -0
  23. package/bundle/server/dist/runners/reflect.js +77 -0
  24. package/bundle/server/dist/runners/run-context.js +168 -0
  25. package/bundle/server/dist/runners/stream-json.js +157 -0
  26. package/bundle/server/dist/static-ui.js +30 -0
  27. package/bundle/server/dist/trace-summary.js +93 -0
  28. package/bundle/server/dist/usage-summary.js +49 -0
  29. package/bundle/server/package.json +38 -0
  30. package/bundle/server/static-ui/apple-touch-icon.png +0 -0
  31. package/bundle/server/static-ui/assets/index-BPfTEO0-.js +77 -0
  32. package/bundle/server/static-ui/assets/index-Blmq-P5I.css +1 -0
  33. package/bundle/server/static-ui/favicon.png +0 -0
  34. package/bundle/server/static-ui/fonts/Monaco.ttf +0 -0
  35. package/bundle/server/static-ui/index.html +17 -0
  36. package/bundle/shared/dist/agents.d.ts +289 -0
  37. package/bundle/shared/dist/agents.js +55 -0
  38. package/bundle/shared/dist/execution.d.ts +14 -0
  39. package/bundle/shared/dist/execution.js +40 -0
  40. package/bundle/shared/dist/index.d.ts +2223 -0
  41. package/bundle/shared/dist/index.js +325 -0
  42. package/bundle/shared/dist/runtime.d.ts +3 -0
  43. package/bundle/shared/dist/runtime.js +2 -0
  44. package/bundle/shared/package.json +32 -0
  45. package/dist/bin.d.ts +2 -0
  46. package/dist/bin.js +8 -0
  47. package/dist/cli-check.d.ts +6 -0
  48. package/dist/cli-check.js +29 -0
  49. package/dist/doctor.d.ts +1 -0
  50. package/dist/doctor.js +74 -0
  51. package/dist/env.d.ts +11 -0
  52. package/dist/env.js +39 -0
  53. package/dist/index.d.ts +1 -0
  54. package/dist/index.js +69 -0
  55. package/dist/paths.d.ts +6 -0
  56. package/dist/paths.js +36 -0
  57. package/dist/postinstall.d.ts +1 -0
  58. package/dist/postinstall.js +19 -0
  59. package/dist/setup.d.ts +6 -0
  60. package/dist/setup.js +29 -0
  61. package/dist/start.d.ts +6 -0
  62. package/dist/start.js +75 -0
  63. package/dist/version.d.ts +1 -0
  64. package/dist/version.js +10 -0
  65. package/package.json +35 -4
  66. package/templates/prod.env.example +24 -0
  67. package/README.md +0 -7
@@ -0,0 +1,136 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { getAgentDeckConfig } from "../repository/intake-settings.js";
4
+ export function getAgentDeckApiUrl() {
5
+ if (process.env.AGENT_DECK_API_URL) {
6
+ return process.env.AGENT_DECK_API_URL.replace(/\/$/, "");
7
+ }
8
+ const cfg = getAgentDeckConfig();
9
+ return `http://${cfg.host}:${cfg.port}`;
10
+ }
11
+ export function getAgentDeckMcpUrl() {
12
+ if (process.env.AGENT_DECK_API_URL) {
13
+ const api = getAgentDeckApiUrl();
14
+ try {
15
+ const u = new URL(api);
16
+ const mcpPort = Number(u.port) - 1;
17
+ return `${u.protocol}//${u.hostname}:${mcpPort}`;
18
+ }
19
+ catch {
20
+ return "http://127.0.0.1:1110";
21
+ }
22
+ }
23
+ const cfg = getAgentDeckConfig();
24
+ return `http://${cfg.host}:${cfg.port - 1}`;
25
+ }
26
+ export async function checkAgentDeckHealth() {
27
+ try {
28
+ const res = await fetch(`${getAgentDeckApiUrl()}/health`, { signal: AbortSignal.timeout(2000) });
29
+ return res.ok;
30
+ }
31
+ catch {
32
+ return false;
33
+ }
34
+ }
35
+ export async function testAgentDeckConnection() {
36
+ const apiUrl = getAgentDeckApiUrl();
37
+ const mcpUrl = getAgentDeckMcpUrl();
38
+ const envOverride = Boolean(process.env.AGENT_DECK_API_URL);
39
+ try {
40
+ const health = await fetch(`${apiUrl}/health`, { signal: AbortSignal.timeout(2000) });
41
+ if (!health.ok) {
42
+ return {
43
+ connected: false,
44
+ apiUrl,
45
+ mcpUrl,
46
+ envOverride,
47
+ error: `HTTP ${health.status}`,
48
+ };
49
+ }
50
+ let deckCount;
51
+ try {
52
+ const decksRes = await fetch(`${apiUrl}/api/decks`, { signal: AbortSignal.timeout(5000) });
53
+ if (decksRes.ok) {
54
+ const json = (await decksRes.json());
55
+ deckCount = json.data?.length;
56
+ }
57
+ }
58
+ catch {
59
+ /* decks optional */
60
+ }
61
+ return { connected: true, apiUrl, mcpUrl, deckCount, envOverride };
62
+ }
63
+ catch (e) {
64
+ return {
65
+ connected: false,
66
+ apiUrl,
67
+ mcpUrl,
68
+ envOverride,
69
+ error: String(e),
70
+ };
71
+ }
72
+ }
73
+ export async function fetchAgentDeckDecks() {
74
+ const res = await fetch(`${getAgentDeckApiUrl()}/api/decks`, { signal: AbortSignal.timeout(5000) });
75
+ if (!res.ok)
76
+ throw new Error(`Agent Deck API error: ${res.status}`);
77
+ return res.json();
78
+ }
79
+ const DASHBOARD_HEADERS = { "x-agent-deck-client": "dashboard" };
80
+ export async function fetchPlaybook(playbookId) {
81
+ const res = await fetch(`${getAgentDeckApiUrl()}/api/playbooks/${playbookId}`, {
82
+ headers: DASHBOARD_HEADERS,
83
+ signal: AbortSignal.timeout(8000),
84
+ });
85
+ if (!res.ok)
86
+ throw new Error(`Agent Deck playbook fetch failed: ${res.status}`);
87
+ const json = (await res.json());
88
+ if (!json.data)
89
+ throw new Error("Playbook not found");
90
+ return json.data;
91
+ }
92
+ export async function updatePlaybookBody(playbookId, body) {
93
+ const res = await fetch(`${getAgentDeckApiUrl()}/api/playbooks/${playbookId}`, {
94
+ method: "PUT",
95
+ headers: { ...DASHBOARD_HEADERS, "Content-Type": "application/json" },
96
+ body: JSON.stringify({ body }),
97
+ signal: AbortSignal.timeout(8000),
98
+ });
99
+ if (!res.ok)
100
+ throw new Error(`Agent Deck playbook update failed: ${res.status}`);
101
+ const json = (await res.json());
102
+ if (!json.data)
103
+ throw new Error("Playbook update failed");
104
+ return json.data;
105
+ }
106
+ export function readClaudeMcpConfigPath() {
107
+ return process.env.CLAUDE_MCP_CONFIG ?? path.join(process.env.HOME ?? "", ".claude.json");
108
+ }
109
+ export function isAgentDeckMcpRegistered() {
110
+ try {
111
+ const configPath = readClaudeMcpConfigPath();
112
+ if (!fs.existsSync(configPath))
113
+ return false;
114
+ const raw = fs.readFileSync(configPath, "utf8");
115
+ const config = JSON.parse(raw);
116
+ const expected = new URL(getAgentDeckMcpUrl().replace(/\/mcp\/?$/, "") + "/mcp");
117
+ for (const [name, server] of Object.entries(config.mcpServers ?? {})) {
118
+ if (!name.toLowerCase().includes("agent-deck") && name !== "agent-deck")
119
+ continue;
120
+ if (!server.url)
121
+ continue;
122
+ try {
123
+ const u = new URL(server.url);
124
+ if (u.hostname === expected.hostname && u.port === expected.port)
125
+ return true;
126
+ }
127
+ catch {
128
+ // skip invalid url
129
+ }
130
+ }
131
+ return false;
132
+ }
133
+ catch {
134
+ return false;
135
+ }
136
+ }
@@ -0,0 +1,109 @@
1
+ import { spawn } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import { claudeBinExists, cursorBinExists, resolveClaudeBin, resolveCursorBin, } from "../cli-env.js";
4
+ import { checkAgentDeckHealth, isAgentDeckMcpRegistered } from "./agent-deck.js";
5
+ const RUNTIME_CACHE_MS = 60_000;
6
+ const runtimeIssueCache = new Map();
7
+ function runCommand(cmd, args, timeoutMs = 8000) {
8
+ return new Promise((resolve) => {
9
+ const child = spawn(cmd, args, {
10
+ stdio: ["ignore", "pipe", "pipe"],
11
+ env: process.env,
12
+ });
13
+ let output = "";
14
+ const timer = setTimeout(() => {
15
+ child.kill("SIGTERM");
16
+ resolve({ ok: false, output: output || "timeout" });
17
+ }, timeoutMs);
18
+ child.stdout?.on("data", (d) => {
19
+ output += d.toString();
20
+ });
21
+ child.stderr?.on("data", (d) => {
22
+ output += d.toString();
23
+ });
24
+ child.on("error", (err) => {
25
+ clearTimeout(timer);
26
+ resolve({ ok: false, output: err.message });
27
+ });
28
+ child.on("close", (code) => {
29
+ clearTimeout(timer);
30
+ resolve({ ok: code === 0, output });
31
+ });
32
+ });
33
+ }
34
+ async function runtimeIssuesUncached(runtime) {
35
+ if (runtime === "claude_code") {
36
+ if (claudeBinExists())
37
+ return [];
38
+ const ver = await runCommand(resolveClaudeBin(), ["--version"]);
39
+ if (!ver.ok) {
40
+ return [{ code: "cli_missing", message: "Claude CLI not found — install Claude Code" }];
41
+ }
42
+ return [];
43
+ }
44
+ const status = await runCommand(resolveCursorBin(), ["agent", "status"]);
45
+ if (!status.ok && !status.output.trim() && !cursorBinExists()) {
46
+ return [{ code: "cli_missing", message: "Cursor CLI not found" }];
47
+ }
48
+ const out = status.output.toLowerCase();
49
+ if (out.includes("not logged in") || out.includes("login required") || out.includes("not authenticated")) {
50
+ return [{ code: "runtime_auth", message: "Run cursor agent login" }];
51
+ }
52
+ return [];
53
+ }
54
+ async function runtimeIssues(runtime) {
55
+ const cached = runtimeIssueCache.get(runtime);
56
+ if (cached && Date.now() - cached.at < RUNTIME_CACHE_MS) {
57
+ return cached.issues;
58
+ }
59
+ const issues = await runtimeIssuesUncached(runtime);
60
+ runtimeIssueCache.set(runtime, { at: Date.now(), issues });
61
+ return issues;
62
+ }
63
+ function agentSpecificIssues(agent, agentDeckOnline, mcpRegistered) {
64
+ const issues = [];
65
+ if (!agent.workspaceRoot) {
66
+ issues.push({ code: "workspace_missing", message: "Set workspace on Agents page" });
67
+ }
68
+ else if (!fs.existsSync(agent.workspaceRoot)) {
69
+ issues.push({
70
+ code: "workspace_missing",
71
+ message: `Workspace path not found: ${agent.workspaceRoot}`,
72
+ });
73
+ }
74
+ if (agent.deckId && !agentDeckOnline) {
75
+ issues.push({ code: "deck_offline", message: "Agent Deck offline — deck MCP unavailable" });
76
+ }
77
+ if (agent.deckId && agent.runtime === "claude_code" && agentDeckOnline && !mcpRegistered) {
78
+ issues.push({
79
+ code: "mcp_not_registered",
80
+ message: "Run agent-deck setup --client claude --start (Claude MCP not registered)",
81
+ });
82
+ }
83
+ return issues;
84
+ }
85
+ export async function healthForAgent(agent, agentDeckOnline, runtimeIssuesByRuntime, mcpRegistered) {
86
+ const runtime = runtimeIssuesByRuntime !== undefined
87
+ ? (runtimeIssuesByRuntime.get(agent.runtime) ?? [])
88
+ : await runtimeIssues(agent.runtime);
89
+ const deckMcpOk = mcpRegistered ?? isAgentDeckMcpRegistered();
90
+ const issues = [
91
+ ...runtime,
92
+ ...agentSpecificIssues(agent, agentDeckOnline, deckMcpOk),
93
+ ];
94
+ return {
95
+ ...agent,
96
+ healthy: issues.length === 0,
97
+ issues,
98
+ };
99
+ }
100
+ export async function listAgentsWithHealth(agents) {
101
+ const agentDeckOnline = await checkAgentDeckHealth();
102
+ const mcpRegistered = isAgentDeckMcpRegistered();
103
+ const runtimes = [...new Set(agents.map((a) => a.runtime))];
104
+ const runtimeIssuesByRuntime = new Map();
105
+ await Promise.all(runtimes.map(async (runtime) => {
106
+ runtimeIssuesByRuntime.set(runtime, await runtimeIssues(runtime));
107
+ }));
108
+ return Promise.all(agents.map((a) => healthForAgent(a, agentDeckOnline, runtimeIssuesByRuntime, mcpRegistered)));
109
+ }
@@ -0,0 +1,21 @@
1
+ import { listLinearCandidates } from "./linear-inbox.js";
2
+ export { checkAgentDeckHealth, fetchAgentDeckDecks } from "./agent-deck.js";
3
+ export async function pollLinearIssues() {
4
+ if (process.env.LINEAR_AUTO_ENQUEUE === "1") {
5
+ console.warn("[linear] LINEAR_AUTO_ENQUEUE=1 is deprecated — use Intake promote");
6
+ return 0;
7
+ }
8
+ if (!process.env.LINEAR_API_KEY)
9
+ return 0;
10
+ try {
11
+ const candidates = await listLinearCandidates();
12
+ if (candidates.length > 0) {
13
+ console.log(`[linear] ${candidates.length} inbox candidate(s) — promote via Intake`);
14
+ }
15
+ return 0;
16
+ }
17
+ catch (e) {
18
+ console.error("[linear]", e);
19
+ return 0;
20
+ }
21
+ }
@@ -0,0 +1,112 @@
1
+ import { findActiveByExternalId, listRunsReadyForPlanReview } from "../repository/runs.js";
2
+ import { getLinearIntakeConfig } from "../repository/intake-settings.js";
3
+ const LINEAR_API = "https://api.linear.app/graphql";
4
+ function hasApiKey() {
5
+ return Boolean(process.env.LINEAR_API_KEY);
6
+ }
7
+ async function linearQuery(query, variables) {
8
+ const key = process.env.LINEAR_API_KEY;
9
+ if (!key)
10
+ throw new Error("LINEAR_API_KEY not set");
11
+ const res = await fetch(LINEAR_API, {
12
+ method: "POST",
13
+ headers: { "Content-Type": "application/json", Authorization: key },
14
+ body: JSON.stringify({ query, variables }),
15
+ });
16
+ if (!res.ok)
17
+ throw new Error(`Linear HTTP ${res.status}: ${await res.text()}`);
18
+ const json = (await res.json());
19
+ if (json.errors?.length)
20
+ throw new Error(JSON.stringify(json.errors));
21
+ return json.data;
22
+ }
23
+ function nodeToCandidate(n) {
24
+ return {
25
+ id: n.id,
26
+ identifier: n.identifier,
27
+ title: n.title,
28
+ description: n.description,
29
+ url: n.url,
30
+ state: n.state?.name,
31
+ teamId: n.team?.id,
32
+ labels: n.labels?.nodes.map((l) => l.name) ?? [],
33
+ };
34
+ }
35
+ const ISSUE_FIELDS = `
36
+ id
37
+ identifier
38
+ title
39
+ description
40
+ url
41
+ state { name }
42
+ team { id }
43
+ labels { nodes { name } }
44
+ `;
45
+ export async function getLinearViewer() {
46
+ if (!hasApiKey())
47
+ return null;
48
+ const data = (await linearQuery(`query { viewer { id name email } }`));
49
+ return data.viewer;
50
+ }
51
+ export async function testLinearConnection() {
52
+ if (!hasApiKey()) {
53
+ return { connected: false, error: "LINEAR_API_KEY not set" };
54
+ }
55
+ try {
56
+ const viewer = await getLinearViewer();
57
+ if (!viewer)
58
+ return { connected: false, error: "No viewer returned" };
59
+ return { connected: true, viewer };
60
+ }
61
+ catch (e) {
62
+ return { connected: false, error: String(e) };
63
+ }
64
+ }
65
+ export function buildIssueFilter(settings, viewerId) {
66
+ const filter = {
67
+ state: { name: { in: settings.stateFilter } },
68
+ };
69
+ if (settings.teamId) {
70
+ filter.team = { id: { eq: settings.teamId } };
71
+ }
72
+ if (settings.assigneeMe && viewerId) {
73
+ filter.assignee = { id: { eq: viewerId } };
74
+ }
75
+ return filter;
76
+ }
77
+ function isPromoted(issueId) {
78
+ return findActiveByExternalId("linear", issueId) !== null;
79
+ }
80
+ export async function listLinearCandidates() {
81
+ if (!hasApiKey())
82
+ return [];
83
+ const settings = getLinearIntakeConfig();
84
+ let viewerId;
85
+ if (settings.assigneeMe) {
86
+ const viewer = await getLinearViewer();
87
+ viewerId = viewer?.id;
88
+ if (!viewerId)
89
+ return [];
90
+ }
91
+ const filter = buildIssueFilter(settings, viewerId);
92
+ const data = (await linearQuery(`query PollIssues($filter: IssueFilter) {
93
+ issues(filter: $filter, first: 30) {
94
+ nodes { ${ISSUE_FIELDS} }
95
+ }
96
+ }`, { filter }));
97
+ return data.issues.nodes
98
+ .filter((n) => !isPromoted(n.id))
99
+ .map(nodeToCandidate);
100
+ }
101
+ export async function getLinearIssue(issueId) {
102
+ const data = (await linearQuery(`query Issue($id: String!) {
103
+ issue(id: $id) { ${ISSUE_FIELDS} }
104
+ }`, { id: issueId }));
105
+ if (!data.issue)
106
+ return null;
107
+ return nodeToCandidate(data.issue);
108
+ }
109
+ /** Runs with a plan ready for human review. */
110
+ export function listAwaitingPlanReview() {
111
+ return listRunsReadyForPlanReview();
112
+ }
@@ -0,0 +1,197 @@
1
+ import { addArtifact, getLatestArtifact, listArtifacts } from "../repository/runs.js";
2
+ import { getLinearIntakeConfig } from "../repository/intake-settings.js";
3
+ import { getLinearIssue } from "./linear-inbox.js";
4
+ const LINEAR_API = "https://api.linear.app/graphql";
5
+ const STATE_BY_EVENT = {
6
+ // TODO(P2): configurable per team — see docs/LINEAR_INTEGRATION.md
7
+ planning_started: "Todo",
8
+ plan_approved: "In Progress",
9
+ review: "In Review",
10
+ retry: "In Progress",
11
+ done: "Done",
12
+ };
13
+ const workflowStateCache = new Map();
14
+ function webBaseUrl() {
15
+ return process.env.AGENT_DEALER_WEB_URL ?? "http://localhost:2222";
16
+ }
17
+ async function linearMutate(query, variables) {
18
+ const key = process.env.LINEAR_API_KEY;
19
+ if (!key)
20
+ throw new Error("LINEAR_API_KEY not set");
21
+ const res = await fetch(LINEAR_API, {
22
+ method: "POST",
23
+ headers: { "Content-Type": "application/json", Authorization: key },
24
+ body: JSON.stringify({ query, variables }),
25
+ });
26
+ if (!res.ok)
27
+ throw new Error(`Linear HTTP ${res.status}: ${await res.text()}`);
28
+ const json = (await res.json());
29
+ if (json.errors?.length)
30
+ throw new Error(JSON.stringify(json.errors));
31
+ return json.data;
32
+ }
33
+ async function getWorkflowStates(teamId) {
34
+ const cached = workflowStateCache.get(teamId);
35
+ if (cached)
36
+ return cached;
37
+ const data = (await linearMutate(`query TeamStates($teamId: String!) {
38
+ team(id: $teamId) {
39
+ states { nodes { id name } }
40
+ }
41
+ }`, { teamId }));
42
+ const map = new Map();
43
+ for (const s of data.team?.states.nodes ?? []) {
44
+ map.set(s.name.toLowerCase(), s.id);
45
+ }
46
+ workflowStateCache.set(teamId, map);
47
+ return map;
48
+ }
49
+ async function commentCreate(issueId, body) {
50
+ await linearMutate(`mutation Comment($issueId: String!, $body: String!) {
51
+ commentCreate(input: { issueId: $issueId, body: $body }) { success }
52
+ }`, { issueId, body });
53
+ }
54
+ async function issueUpdateState(issueId, stateId) {
55
+ await linearMutate(`mutation UpdateIssue($issueId: String!, $stateId: String!) {
56
+ issueUpdate(id: $issueId, input: { stateId: $stateId }) { success }
57
+ }`, { issueId, stateId });
58
+ }
59
+ function planExcerpt(run) {
60
+ const plan = getLatestArtifact(run.id, "approved_plan");
61
+ if (!plan?.contentJson)
62
+ return "";
63
+ try {
64
+ const parsed = JSON.parse(plan.contentJson);
65
+ const md = parsed.markdown?.trim() ?? "";
66
+ return md.length > 600 ? `${md.slice(0, 600)}…` : md;
67
+ }
68
+ catch {
69
+ return "";
70
+ }
71
+ }
72
+ function resultExcerpt(run) {
73
+ const result = getLatestArtifact(run.id, "execution_result");
74
+ if (result?.contentJson) {
75
+ try {
76
+ const parsed = JSON.parse(result.contentJson);
77
+ const text = parsed.resultText?.trim() ?? "";
78
+ if (text)
79
+ return text.length > 500 ? `${text.slice(0, 500)}…` : text;
80
+ }
81
+ catch {
82
+ /* fall through */
83
+ }
84
+ }
85
+ const doc = listArtifacts(run.id).find((a) => a.kind === "document");
86
+ if (doc?.contentJson) {
87
+ try {
88
+ const parsed = JSON.parse(doc.contentJson);
89
+ const text = parsed.markdown?.trim() ?? "";
90
+ if (text)
91
+ return text.length > 500 ? `${text.slice(0, 500)}…` : text;
92
+ }
93
+ catch {
94
+ /* ignore */
95
+ }
96
+ }
97
+ return "";
98
+ }
99
+ function buildComment(run, event) {
100
+ const label = run.externalLabel ?? run.externalId ?? run.id;
101
+ const link = `${webBaseUrl()}/?run=${run.id}`;
102
+ if (event === "planning_started") {
103
+ return [
104
+ `**agent-dealer** — planning started for ${label}`,
105
+ ``,
106
+ `_Agent is drafting a plan._`,
107
+ ``,
108
+ `[Open run](${link})`,
109
+ ].join("\n");
110
+ }
111
+ if (event === "plan_approved") {
112
+ const excerpt = planExcerpt(run);
113
+ return [
114
+ `**agent-dealer** — plan approved for ${label}`,
115
+ ``,
116
+ excerpt ? excerpt : `_Plan approved — see run for details._`,
117
+ ``,
118
+ `[Open run](${link})`,
119
+ ].join("\n");
120
+ }
121
+ if (event === "review") {
122
+ const excerpt = resultExcerpt(run);
123
+ return [
124
+ `**agent-dealer** — execution complete, awaiting review (${label})`,
125
+ ``,
126
+ excerpt ? excerpt : `_Result ready for human review._`,
127
+ ``,
128
+ `[Review run](${link})`,
129
+ ].join("\n");
130
+ }
131
+ if (event === "retry") {
132
+ return [
133
+ `**agent-dealer** — retry requested for ${label}`,
134
+ ``,
135
+ `_Re-executing with human feedback._`,
136
+ ``,
137
+ `[Open run](${link})`,
138
+ ].join("\n");
139
+ }
140
+ return [
141
+ `**agent-dealer** — approved and marked done (${label})`,
142
+ ``,
143
+ `[View run](${link})`,
144
+ ].join("\n");
145
+ }
146
+ function recordSyncAttempt(run, event, ok, detail) {
147
+ addArtifact(run.id, "linear_sync", { event, ok, at: new Date().toISOString(), ...detail }, "system");
148
+ }
149
+ function hasSuccessfulSync(runId, event) {
150
+ return listArtifacts(runId).some((a) => {
151
+ if (a.kind !== "linear_sync" || !a.contentJson)
152
+ return false;
153
+ try {
154
+ const parsed = JSON.parse(a.contentJson);
155
+ return parsed.event === event && parsed.ok === true;
156
+ }
157
+ catch {
158
+ return false;
159
+ }
160
+ });
161
+ }
162
+ /** Non-blocking Linear write-back — callers should `.catch()` and never fail the human action. */
163
+ export async function syncLinearForRun(run, event) {
164
+ const settings = getLinearIntakeConfig();
165
+ if (!settings.syncEnabled || !process.env.LINEAR_API_KEY || !run.externalId) {
166
+ return;
167
+ }
168
+ if (event === "planning_started" && hasSuccessfulSync(run.id, event)) {
169
+ return;
170
+ }
171
+ const issue = await getLinearIssue(run.externalId);
172
+ if (!issue?.teamId) {
173
+ recordSyncAttempt(run, event, false, { error: "Issue or teamId not found" });
174
+ return;
175
+ }
176
+ const targetStateName = STATE_BY_EVENT[event];
177
+ const states = await getWorkflowStates(issue.teamId);
178
+ const stateId = states.get(targetStateName.toLowerCase());
179
+ try {
180
+ await commentCreate(run.externalId, buildComment(run, event));
181
+ if (stateId) {
182
+ await issueUpdateState(run.externalId, stateId);
183
+ }
184
+ else {
185
+ recordSyncAttempt(run, event, false, {
186
+ error: `Workflow state not found: ${targetStateName}`,
187
+ teamId: issue.teamId,
188
+ });
189
+ return;
190
+ }
191
+ recordSyncAttempt(run, event, true, { state: targetStateName });
192
+ }
193
+ catch (e) {
194
+ recordSyncAttempt(run, event, false, { error: String(e) });
195
+ console.error(`[linear-sync] ${event} for run ${run.id}:`, e);
196
+ }
197
+ }
@@ -0,0 +1,62 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ /** Paths where Claude Code / Cursor CLI are commonly installed outside login-shell PATH. */
5
+ function commonCliDirs(home) {
6
+ return [
7
+ path.join(home, ".local/bin"),
8
+ path.join(home, ".cursor/bin"),
9
+ "/opt/homebrew/bin",
10
+ "/usr/local/bin",
11
+ ];
12
+ }
13
+ /**
14
+ * Prepend common CLI install dirs to PATH.
15
+ * Cursor/npm dev servers often inherit a minimal PATH without ~/.local/bin from .zshrc.
16
+ */
17
+ export function enrichPathForCliTools() {
18
+ const home = process.env.HOME ?? os.homedir();
19
+ const prepend = commonCliDirs(home);
20
+ const current = process.env.PATH ?? "";
21
+ const parts = [...prepend, ...current.split(path.delimiter)].filter((p, i, arr) => p.length > 0 && arr.indexOf(p) === i);
22
+ process.env.PATH = parts.join(path.delimiter);
23
+ }
24
+ function firstExisting(candidates) {
25
+ for (const candidate of candidates) {
26
+ if (fs.existsSync(candidate))
27
+ return candidate;
28
+ }
29
+ return null;
30
+ }
31
+ /** Resolve Claude Code binary — avoids PATH misses in IDE-spawned servers. */
32
+ export function resolveClaudeBin() {
33
+ const home = process.env.HOME ?? os.homedir();
34
+ if (process.env.CLAUDE_CLI)
35
+ return process.env.CLAUDE_CLI;
36
+ return (firstExisting([
37
+ path.join(home, ".local/bin/claude"),
38
+ path.join(home, ".cursor/bin/claude"),
39
+ "/opt/homebrew/bin/claude",
40
+ "/usr/local/bin/claude",
41
+ ]) ?? "claude");
42
+ }
43
+ /** Resolve Cursor CLI binary. */
44
+ export function resolveCursorBin() {
45
+ const home = process.env.HOME ?? os.homedir();
46
+ if (process.env.CURSOR_CLI)
47
+ return process.env.CURSOR_CLI;
48
+ return (firstExisting([
49
+ path.join(home, ".local/bin/cursor"),
50
+ path.join(home, ".cursor/bin/cursor"),
51
+ "/opt/homebrew/bin/cursor",
52
+ "/usr/local/bin/cursor",
53
+ ]) ?? "cursor");
54
+ }
55
+ export function claudeBinExists() {
56
+ const bin = resolveClaudeBin();
57
+ return bin !== "claude" ? fs.existsSync(bin) : false;
58
+ }
59
+ export function cursorBinExists() {
60
+ const bin = resolveCursorBin();
61
+ return bin !== "cursor" ? fs.existsSync(bin) : false;
62
+ }