gsd-pi 2.10.2 → 2.10.5

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 (136) hide show
  1. package/README.md +2 -0
  2. package/dist/cli.js +7 -0
  3. package/dist/loader.js +1 -0
  4. package/dist/onboarding.js +104 -59
  5. package/dist/update-cmd.d.ts +1 -0
  6. package/dist/update-cmd.js +40 -0
  7. package/node_modules/@gsd/native/dist/hasher/index.d.ts +32 -0
  8. package/node_modules/@gsd/native/dist/hasher/index.js +37 -0
  9. package/node_modules/@gsd/native/dist/native.d.ts +4 -1
  10. package/node_modules/@gsd/native/dist/native.js +39 -9
  11. package/node_modules/@gsd/native/dist/xxhash/index.d.ts +14 -0
  12. package/node_modules/@gsd/native/dist/xxhash/index.js +17 -0
  13. package/node_modules/@gsd/pi-coding-agent/dist/core/agent-session.d.ts +6 -0
  14. package/node_modules/@gsd/pi-coding-agent/dist/core/agent-session.d.ts.map +1 -1
  15. package/node_modules/@gsd/pi-coding-agent/dist/core/agent-session.js +58 -9
  16. package/node_modules/@gsd/pi-coding-agent/dist/core/agent-session.js.map +1 -1
  17. package/node_modules/@gsd/pi-coding-agent/dist/core/auth-storage.d.ts +72 -12
  18. package/node_modules/@gsd/pi-coding-agent/dist/core/auth-storage.d.ts.map +1 -1
  19. package/node_modules/@gsd/pi-coding-agent/dist/core/auth-storage.js +254 -43
  20. package/node_modules/@gsd/pi-coding-agent/dist/core/auth-storage.js.map +1 -1
  21. package/node_modules/@gsd/pi-coding-agent/dist/core/auth-storage.test.d.ts +2 -0
  22. package/node_modules/@gsd/pi-coding-agent/dist/core/auth-storage.test.d.ts.map +1 -0
  23. package/node_modules/@gsd/pi-coding-agent/dist/core/auth-storage.test.js +159 -0
  24. package/node_modules/@gsd/pi-coding-agent/dist/core/auth-storage.test.js.map +1 -0
  25. package/node_modules/@gsd/pi-coding-agent/dist/core/model-registry.d.ts +4 -2
  26. package/node_modules/@gsd/pi-coding-agent/dist/core/model-registry.d.ts.map +1 -1
  27. package/node_modules/@gsd/pi-coding-agent/dist/core/model-registry.js +6 -4
  28. package/node_modules/@gsd/pi-coding-agent/dist/core/model-registry.js.map +1 -1
  29. package/node_modules/@gsd/pi-coding-agent/dist/core/settings-manager.d.ts +15 -0
  30. package/node_modules/@gsd/pi-coding-agent/dist/core/settings-manager.d.ts.map +1 -1
  31. package/node_modules/@gsd/pi-coding-agent/dist/core/settings-manager.js +12 -0
  32. package/node_modules/@gsd/pi-coding-agent/dist/core/settings-manager.js.map +1 -1
  33. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash-interceptor.d.ts +40 -0
  34. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash-interceptor.d.ts.map +1 -0
  35. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash-interceptor.js +92 -0
  36. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash-interceptor.js.map +1 -0
  37. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash-interceptor.test.d.ts +2 -0
  38. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash-interceptor.test.d.ts.map +1 -0
  39. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash-interceptor.test.js +156 -0
  40. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash-interceptor.test.js.map +1 -0
  41. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash.d.ts +8 -0
  42. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash.d.ts.map +1 -1
  43. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash.js +18 -0
  44. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash.js.map +1 -1
  45. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/index.d.ts +1 -0
  46. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/index.d.ts.map +1 -1
  47. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/index.js +1 -0
  48. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/index.js.map +1 -1
  49. package/node_modules/@gsd/pi-coding-agent/dist/index.d.ts +2 -2
  50. package/node_modules/@gsd/pi-coding-agent/dist/index.d.ts.map +1 -1
  51. package/node_modules/@gsd/pi-coding-agent/dist/index.js +1 -1
  52. package/node_modules/@gsd/pi-coding-agent/dist/index.js.map +1 -1
  53. package/node_modules/@gsd/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  54. package/node_modules/@gsd/pi-coding-agent/dist/modes/interactive/interactive-mode.js +1 -1
  55. package/node_modules/@gsd/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  56. package/node_modules/@gsd/pi-coding-agent/src/core/agent-session.ts +65 -9
  57. package/node_modules/@gsd/pi-coding-agent/src/core/auth-storage.test.ts +194 -0
  58. package/node_modules/@gsd/pi-coding-agent/src/core/auth-storage.ts +283 -53
  59. package/node_modules/@gsd/pi-coding-agent/src/core/model-registry.ts +6 -4
  60. package/node_modules/@gsd/pi-coding-agent/src/core/settings-manager.ts +29 -0
  61. package/node_modules/@gsd/pi-coding-agent/src/core/tools/bash-interceptor.test.ts +198 -0
  62. package/node_modules/@gsd/pi-coding-agent/src/core/tools/bash-interceptor.ts +115 -0
  63. package/node_modules/@gsd/pi-coding-agent/src/core/tools/bash.ts +29 -0
  64. package/node_modules/@gsd/pi-coding-agent/src/core/tools/index.ts +8 -0
  65. package/node_modules/@gsd/pi-coding-agent/src/index.ts +6 -0
  66. package/node_modules/@gsd/pi-coding-agent/src/modes/interactive/interactive-mode.ts +1 -1
  67. package/package.json +8 -2
  68. package/packages/native/dist/hasher/index.d.ts +32 -0
  69. package/packages/native/dist/hasher/index.js +37 -0
  70. package/packages/native/dist/native.d.ts +4 -1
  71. package/packages/native/dist/native.js +39 -9
  72. package/packages/native/dist/xxhash/index.d.ts +14 -0
  73. package/packages/native/dist/xxhash/index.js +17 -0
  74. package/packages/native/src/native.ts +39 -9
  75. package/packages/pi-coding-agent/dist/core/agent-session.d.ts +6 -0
  76. package/packages/pi-coding-agent/dist/core/agent-session.d.ts.map +1 -1
  77. package/packages/pi-coding-agent/dist/core/agent-session.js +58 -9
  78. package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
  79. package/packages/pi-coding-agent/dist/core/auth-storage.d.ts +72 -12
  80. package/packages/pi-coding-agent/dist/core/auth-storage.d.ts.map +1 -1
  81. package/packages/pi-coding-agent/dist/core/auth-storage.js +254 -43
  82. package/packages/pi-coding-agent/dist/core/auth-storage.js.map +1 -1
  83. package/packages/pi-coding-agent/dist/core/auth-storage.test.d.ts +2 -0
  84. package/packages/pi-coding-agent/dist/core/auth-storage.test.d.ts.map +1 -0
  85. package/packages/pi-coding-agent/dist/core/auth-storage.test.js +159 -0
  86. package/packages/pi-coding-agent/dist/core/auth-storage.test.js.map +1 -0
  87. package/packages/pi-coding-agent/dist/core/model-registry.d.ts +4 -2
  88. package/packages/pi-coding-agent/dist/core/model-registry.d.ts.map +1 -1
  89. package/packages/pi-coding-agent/dist/core/model-registry.js +6 -4
  90. package/packages/pi-coding-agent/dist/core/model-registry.js.map +1 -1
  91. package/packages/pi-coding-agent/dist/core/settings-manager.d.ts +15 -0
  92. package/packages/pi-coding-agent/dist/core/settings-manager.d.ts.map +1 -1
  93. package/packages/pi-coding-agent/dist/core/settings-manager.js +12 -0
  94. package/packages/pi-coding-agent/dist/core/settings-manager.js.map +1 -1
  95. package/packages/pi-coding-agent/dist/core/tools/bash-interceptor.d.ts +40 -0
  96. package/packages/pi-coding-agent/dist/core/tools/bash-interceptor.d.ts.map +1 -0
  97. package/packages/pi-coding-agent/dist/core/tools/bash-interceptor.js +92 -0
  98. package/packages/pi-coding-agent/dist/core/tools/bash-interceptor.js.map +1 -0
  99. package/packages/pi-coding-agent/dist/core/tools/bash-interceptor.test.d.ts +2 -0
  100. package/packages/pi-coding-agent/dist/core/tools/bash-interceptor.test.d.ts.map +1 -0
  101. package/packages/pi-coding-agent/dist/core/tools/bash-interceptor.test.js +156 -0
  102. package/packages/pi-coding-agent/dist/core/tools/bash-interceptor.test.js.map +1 -0
  103. package/packages/pi-coding-agent/dist/core/tools/bash.d.ts +8 -0
  104. package/packages/pi-coding-agent/dist/core/tools/bash.d.ts.map +1 -1
  105. package/packages/pi-coding-agent/dist/core/tools/bash.js +18 -0
  106. package/packages/pi-coding-agent/dist/core/tools/bash.js.map +1 -1
  107. package/packages/pi-coding-agent/dist/core/tools/index.d.ts +1 -0
  108. package/packages/pi-coding-agent/dist/core/tools/index.d.ts.map +1 -1
  109. package/packages/pi-coding-agent/dist/core/tools/index.js +1 -0
  110. package/packages/pi-coding-agent/dist/core/tools/index.js.map +1 -1
  111. package/packages/pi-coding-agent/dist/index.d.ts +2 -2
  112. package/packages/pi-coding-agent/dist/index.d.ts.map +1 -1
  113. package/packages/pi-coding-agent/dist/index.js +1 -1
  114. package/packages/pi-coding-agent/dist/index.js.map +1 -1
  115. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  116. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +1 -1
  117. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  118. package/packages/pi-coding-agent/src/core/agent-session.ts +65 -9
  119. package/packages/pi-coding-agent/src/core/auth-storage.test.ts +194 -0
  120. package/packages/pi-coding-agent/src/core/auth-storage.ts +283 -53
  121. package/packages/pi-coding-agent/src/core/model-registry.ts +6 -4
  122. package/packages/pi-coding-agent/src/core/settings-manager.ts +29 -0
  123. package/packages/pi-coding-agent/src/core/tools/bash-interceptor.test.ts +198 -0
  124. package/packages/pi-coding-agent/src/core/tools/bash-interceptor.ts +115 -0
  125. package/packages/pi-coding-agent/src/core/tools/bash.ts +29 -0
  126. package/packages/pi-coding-agent/src/core/tools/index.ts +8 -0
  127. package/packages/pi-coding-agent/src/index.ts +6 -0
  128. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +1 -1
  129. package/src/resources/extensions/async-jobs/async-bash-tool.ts +211 -0
  130. package/src/resources/extensions/async-jobs/await-tool.ts +101 -0
  131. package/src/resources/extensions/async-jobs/cancel-job-tool.ts +34 -0
  132. package/src/resources/extensions/async-jobs/index.ts +133 -0
  133. package/src/resources/extensions/async-jobs/job-manager.ts +250 -0
  134. package/src/resources/extensions/gsd/git-service.ts +13 -3
  135. package/src/resources/extensions/gsd/prompts/system.md +5 -2
  136. package/src/resources/extensions/gsd/tests/git-service.test.ts +36 -0
@@ -0,0 +1,101 @@
1
+ /**
2
+ * await_job tool — wait for one or more background jobs to complete.
3
+ *
4
+ * If specific job IDs are provided, waits for those jobs.
5
+ * If omitted, waits for any running job to complete.
6
+ */
7
+
8
+ import type { ToolDefinition } from "@gsd/pi-coding-agent";
9
+ import { Type } from "@sinclair/typebox";
10
+ import type { AsyncJobManager, Job } from "./job-manager.js";
11
+
12
+ const schema = Type.Object({
13
+ jobs: Type.Optional(
14
+ Type.Array(Type.String(), {
15
+ description: "Job IDs to wait for. Omit to wait for any running job.",
16
+ }),
17
+ ),
18
+ });
19
+
20
+ export function createAwaitTool(getManager: () => AsyncJobManager): ToolDefinition<typeof schema> {
21
+ return {
22
+ name: "await_job",
23
+ label: "Await Background Job",
24
+ description:
25
+ "Wait for background jobs to complete. Provide specific job IDs or omit to wait for the next job that finishes. Returns results of completed jobs.",
26
+ parameters: schema,
27
+ async execute(_toolCallId, params) {
28
+ const manager = getManager();
29
+ const { jobs: jobIds } = params;
30
+
31
+ let watched: Job[];
32
+ if (jobIds && jobIds.length > 0) {
33
+ watched = [];
34
+ const notFound: string[] = [];
35
+ for (const id of jobIds) {
36
+ const job = manager.getJob(id);
37
+ if (job) {
38
+ watched.push(job);
39
+ } else {
40
+ notFound.push(id);
41
+ }
42
+ }
43
+ if (notFound.length > 0 && watched.length === 0) {
44
+ return {
45
+ content: [{ type: "text", text: `No jobs found: ${notFound.join(", ")}` }],
46
+ };
47
+ }
48
+ } else {
49
+ watched = manager.getRunningJobs();
50
+ if (watched.length === 0) {
51
+ return {
52
+ content: [{ type: "text", text: "No running background jobs." }],
53
+ };
54
+ }
55
+ }
56
+
57
+ // If all watched jobs are already done, return immediately
58
+ const running = watched.filter((j) => j.status === "running");
59
+ if (running.length === 0) {
60
+ const result = formatResults(watched);
61
+ manager.acknowledgeDeliveries(watched.map((j) => j.id));
62
+ return { content: [{ type: "text", text: result }] };
63
+ }
64
+
65
+ // Wait for at least one to complete
66
+ await Promise.race(running.map((j) => j.promise));
67
+
68
+ // Collect all completed results (more may have finished while waiting)
69
+ const completed = watched.filter((j) => j.status !== "running");
70
+ manager.acknowledgeDeliveries(completed.map((j) => j.id));
71
+
72
+ const stillRunning = watched.filter((j) => j.status === "running");
73
+ let result = formatResults(completed);
74
+ if (stillRunning.length > 0) {
75
+ result += `\n\n**Still running:** ${stillRunning.map((j) => `${j.id} (${j.label})`).join(", ")}`;
76
+ }
77
+
78
+ return { content: [{ type: "text", text: result }] };
79
+ },
80
+ };
81
+ }
82
+
83
+ function formatResults(jobs: Job[]): string {
84
+ if (jobs.length === 0) return "No completed jobs.";
85
+
86
+ const parts: string[] = [];
87
+ for (const job of jobs) {
88
+ const elapsed = ((Date.now() - job.startTime) / 1000).toFixed(1);
89
+ const header = `### ${job.id} — ${job.label} (${job.status}, ${elapsed}s)`;
90
+
91
+ if (job.status === "completed") {
92
+ parts.push(`${header}\n\n${job.resultText ?? "(no output)"}`);
93
+ } else if (job.status === "failed") {
94
+ parts.push(`${header}\n\nError: ${job.errorText ?? "unknown error"}`);
95
+ } else if (job.status === "cancelled") {
96
+ parts.push(`${header}\n\nCancelled.`);
97
+ }
98
+ }
99
+
100
+ return parts.join("\n\n---\n\n");
101
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * cancel_job tool — cancel a running background job.
3
+ */
4
+
5
+ import type { ToolDefinition } from "@gsd/pi-coding-agent";
6
+ import { Type } from "@sinclair/typebox";
7
+ import type { AsyncJobManager } from "./job-manager.js";
8
+
9
+ const schema = Type.Object({
10
+ job_id: Type.String({ description: "The background job ID to cancel (e.g. bg_a1b2c3d4)" }),
11
+ });
12
+
13
+ export function createCancelJobTool(getManager: () => AsyncJobManager): ToolDefinition<typeof schema> {
14
+ return {
15
+ name: "cancel_job",
16
+ label: "Cancel Background Job",
17
+ description: "Cancel a running background job by its ID.",
18
+ parameters: schema,
19
+ async execute(_toolCallId, params) {
20
+ const manager = getManager();
21
+ const result = manager.cancel(params.job_id);
22
+
23
+ const messages: Record<string, string> = {
24
+ cancelled: `Job ${params.job_id} has been cancelled.`,
25
+ not_found: `Job ${params.job_id} not found.`,
26
+ already_completed: `Job ${params.job_id} has already completed (or failed/cancelled).`,
27
+ };
28
+
29
+ return {
30
+ content: [{ type: "text", text: messages[result] ?? `Unknown result: ${result}` }],
31
+ };
32
+ },
33
+ };
34
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Async Jobs Extension
3
+ *
4
+ * Allows bash commands to run in the background. The agent gets a job ID
5
+ * immediately and can continue working. Results are delivered via follow-up
6
+ * messages when jobs complete.
7
+ *
8
+ * Tools:
9
+ * async_bash — run a command in the background, get a job ID
10
+ * await_job — wait for background jobs to complete, get results
11
+ * cancel_job — cancel a running background job
12
+ *
13
+ * Commands:
14
+ * /jobs — show running and recent background jobs
15
+ */
16
+
17
+ import type { ExtensionAPI, ExtensionCommandContext } from "@gsd/pi-coding-agent";
18
+ import { AsyncJobManager, type Job } from "./job-manager.js";
19
+ import { createAsyncBashTool } from "./async-bash-tool.js";
20
+ import { createAwaitTool } from "./await-tool.js";
21
+ import { createCancelJobTool } from "./cancel-job-tool.js";
22
+
23
+ export default function AsyncJobs(pi: ExtensionAPI) {
24
+ let manager: AsyncJobManager | null = null;
25
+ let latestCwd: string = process.cwd();
26
+
27
+ function getManager(): AsyncJobManager {
28
+ if (!manager) {
29
+ throw new Error("AsyncJobManager not initialized. Wait for session_start.");
30
+ }
31
+ return manager;
32
+ }
33
+
34
+ function getCwd(): string {
35
+ return latestCwd;
36
+ }
37
+
38
+ // ── Session lifecycle ──────────────────────────────────────────────────
39
+
40
+ pi.on("session_start", async (_event, ctx) => {
41
+ latestCwd = ctx.cwd;
42
+
43
+ manager = new AsyncJobManager({
44
+ onJobComplete: (job) => {
45
+ const statusEmoji = job.status === "completed" ? "done" : "error";
46
+ const elapsed = ((Date.now() - job.startTime) / 1000).toFixed(1);
47
+ const output = job.status === "completed"
48
+ ? job.resultText ?? "(no output)"
49
+ : `Error: ${job.errorText ?? "unknown error"}`;
50
+
51
+ // Truncate output for the follow-up message
52
+ const maxLen = 2000;
53
+ const truncatedOutput = output.length > maxLen
54
+ ? output.slice(0, maxLen) + "\n\n[... truncated, use await_job for full output]"
55
+ : output;
56
+
57
+ pi.sendMessage(
58
+ {
59
+ customType: "async_job_result",
60
+ content: [
61
+ `**Background job ${statusEmoji}: ${job.id}** (${job.label}, ${elapsed}s)`,
62
+ "",
63
+ truncatedOutput,
64
+ ].join("\n"),
65
+ display: `Background job ${job.id} ${job.status}`,
66
+ },
67
+ { deliverAs: "followUp", triggerTurn: true },
68
+ );
69
+ },
70
+ });
71
+ });
72
+
73
+ pi.on("session_shutdown", async () => {
74
+ if (manager) {
75
+ manager.shutdown();
76
+ manager = null;
77
+ }
78
+ });
79
+
80
+ // ── Tools ──────────────────────────────────────────────────────────────
81
+
82
+ pi.registerTool(createAsyncBashTool(getManager, getCwd));
83
+ pi.registerTool(createAwaitTool(getManager));
84
+ pi.registerTool(createCancelJobTool(getManager));
85
+
86
+ // ── /jobs command ──────────────────────────────────────────────────────
87
+
88
+ pi.registerCommand("jobs", {
89
+ description: "Show running and recent background jobs",
90
+ handler: async (_args: string, _ctx: ExtensionCommandContext) => {
91
+ if (!manager) {
92
+ pi.sendMessage({
93
+ customType: "async_jobs_list",
94
+ content: "No async job manager active.",
95
+ display: "No jobs",
96
+ });
97
+ return;
98
+ }
99
+
100
+ const running = manager.getRunningJobs();
101
+ const recent = manager.getRecentJobs(10);
102
+ const completed = recent.filter((j) => j.status !== "running");
103
+
104
+ const lines: string[] = ["## Background Jobs"];
105
+
106
+ if (running.length === 0 && completed.length === 0) {
107
+ lines.push("", "No background jobs.");
108
+ } else {
109
+ if (running.length > 0) {
110
+ lines.push("", "### Running");
111
+ for (const job of running) {
112
+ const elapsed = ((Date.now() - job.startTime) / 1000).toFixed(0);
113
+ lines.push(`- **${job.id}** — ${job.label} (${elapsed}s)`);
114
+ }
115
+ }
116
+
117
+ if (completed.length > 0) {
118
+ lines.push("", "### Recent");
119
+ for (const job of completed) {
120
+ const elapsed = ((Date.now() - job.startTime) / 1000).toFixed(1);
121
+ lines.push(`- **${job.id}** — ${job.label} (${job.status}, ${elapsed}s)`);
122
+ }
123
+ }
124
+ }
125
+
126
+ pi.sendMessage({
127
+ customType: "async_jobs_list",
128
+ content: lines.join("\n"),
129
+ display: `${running.length} running, ${completed.length} recent`,
130
+ });
131
+ },
132
+ });
133
+ }
@@ -0,0 +1,250 @@
1
+ /**
2
+ * AsyncJobManager — manages background tool call jobs.
3
+ *
4
+ * Each job runs asynchronously and delivers its result via a callback
5
+ * when complete. Jobs are evicted after a configurable TTL.
6
+ */
7
+
8
+ import { randomUUID } from "node:crypto";
9
+
10
+ // ── Types ──────────────────────────────────────────────────────────────────
11
+
12
+ export type JobStatus = "running" | "completed" | "failed" | "cancelled";
13
+ export type JobType = "bash";
14
+
15
+ export interface Job {
16
+ id: string;
17
+ type: JobType;
18
+ status: JobStatus;
19
+ startTime: number;
20
+ label: string;
21
+ abortController: AbortController;
22
+ promise: Promise<void>;
23
+ resultText?: string;
24
+ errorText?: string;
25
+ }
26
+
27
+ export interface JobManagerOptions {
28
+ maxRunning?: number; // default 15
29
+ maxTotal?: number; // default 100
30
+ evictionMs?: number; // default 5 minutes
31
+ onJobComplete?: (job: Job) => void;
32
+ }
33
+
34
+ // ── Delivery Retry ─────────────────────────────────────────────────────────
35
+
36
+ const DELIVERY_BASE_MS = 500;
37
+ const DELIVERY_MAX_MS = 30_000;
38
+ const DELIVERY_JITTER_MS = 200;
39
+
40
+ // ── Manager ────────────────────────────────────────────────────────────────
41
+
42
+ export class AsyncJobManager {
43
+ private jobs = new Map<string, Job>();
44
+ private deliveryTimers = new Map<string, ReturnType<typeof setTimeout>>();
45
+ private acknowledgedJobs = new Set<string>();
46
+ private evictionTimers = new Map<string, ReturnType<typeof setTimeout>>();
47
+
48
+ private maxRunning: number;
49
+ private maxTotal: number;
50
+ private evictionMs: number;
51
+ private onJobComplete?: (job: Job) => void;
52
+
53
+ constructor(options: JobManagerOptions = {}) {
54
+ this.maxRunning = options.maxRunning ?? 15;
55
+ this.maxTotal = options.maxTotal ?? 100;
56
+ this.evictionMs = options.evictionMs ?? 5 * 60 * 1000;
57
+ this.onJobComplete = options.onJobComplete;
58
+ }
59
+
60
+ /**
61
+ * Register a new background job.
62
+ * @returns job ID (prefixed with `bg_`)
63
+ */
64
+ register(
65
+ type: JobType,
66
+ label: string,
67
+ runFn: (signal: AbortSignal) => Promise<string>,
68
+ ): string {
69
+ // Enforce limits
70
+ const running = this.getRunningJobs();
71
+ if (running.length >= this.maxRunning) {
72
+ throw new Error(
73
+ `Maximum concurrent background jobs reached (${this.maxRunning}). ` +
74
+ `Use await_job or cancel_job to free a slot.`,
75
+ );
76
+ }
77
+ if (this.jobs.size >= this.maxTotal) {
78
+ // Evict oldest completed job
79
+ this.evictOldest();
80
+ if (this.jobs.size >= this.maxTotal) {
81
+ throw new Error(
82
+ `Maximum total background jobs reached (${this.maxTotal}). ` +
83
+ `Use cancel_job to remove jobs.`,
84
+ );
85
+ }
86
+ }
87
+
88
+ const id = `bg_${randomUUID().slice(0, 8)}`;
89
+ const abortController = new AbortController();
90
+
91
+ // Declare job first so the promise callbacks can close over it safely.
92
+ const job: Job = {
93
+ id,
94
+ type,
95
+ status: "running",
96
+ startTime: Date.now(),
97
+ label,
98
+ abortController,
99
+ // promise assigned below
100
+ promise: undefined as unknown as Promise<void>,
101
+ };
102
+
103
+ job.promise = runFn(abortController.signal)
104
+ .then((resultText) => {
105
+ job.status = "completed";
106
+ job.resultText = resultText;
107
+ this.scheduleEviction(id);
108
+ this.deliverResult(job);
109
+ })
110
+ .catch((err) => {
111
+ if (job.status === "cancelled") {
112
+ // Already cancelled — don't overwrite
113
+ this.scheduleEviction(id);
114
+ return;
115
+ }
116
+ job.status = "failed";
117
+ job.errorText = err instanceof Error ? err.message : String(err);
118
+ this.scheduleEviction(id);
119
+ this.deliverResult(job);
120
+ });
121
+
122
+ this.jobs.set(id, job);
123
+ return id;
124
+ }
125
+
126
+ /**
127
+ * Cancel a running job.
128
+ */
129
+ cancel(id: string): "cancelled" | "not_found" | "already_completed" {
130
+ const job = this.jobs.get(id);
131
+ if (!job) return "not_found";
132
+ if (job.status !== "running") return "already_completed";
133
+
134
+ job.status = "cancelled";
135
+ job.errorText = "Cancelled by user";
136
+ job.abortController.abort();
137
+ this.scheduleEviction(id);
138
+ return "cancelled";
139
+ }
140
+
141
+ getJob(id: string): Job | undefined {
142
+ return this.jobs.get(id);
143
+ }
144
+
145
+ getRunningJobs(): Job[] {
146
+ return [...this.jobs.values()].filter((j) => j.status === "running");
147
+ }
148
+
149
+ getRecentJobs(limit = 10): Job[] {
150
+ return [...this.jobs.values()]
151
+ .sort((a, b) => b.startTime - a.startTime)
152
+ .slice(0, limit);
153
+ }
154
+
155
+ getAllJobs(): Job[] {
156
+ return [...this.jobs.values()];
157
+ }
158
+
159
+ /**
160
+ * Mark jobs as acknowledged so delivery retries stop.
161
+ */
162
+ acknowledgeDeliveries(jobIds: string[]): void {
163
+ for (const id of jobIds) {
164
+ this.acknowledgedJobs.add(id);
165
+ const timer = this.deliveryTimers.get(id);
166
+ if (timer) {
167
+ clearTimeout(timer);
168
+ this.deliveryTimers.delete(id);
169
+ }
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Cleanup all timers and resources.
175
+ */
176
+ shutdown(): void {
177
+ for (const timer of this.deliveryTimers.values()) {
178
+ clearTimeout(timer);
179
+ }
180
+ this.deliveryTimers.clear();
181
+
182
+ for (const timer of this.evictionTimers.values()) {
183
+ clearTimeout(timer);
184
+ }
185
+ this.evictionTimers.clear();
186
+
187
+ // Abort all running jobs
188
+ for (const job of this.jobs.values()) {
189
+ if (job.status === "running") {
190
+ job.status = "cancelled";
191
+ job.abortController.abort();
192
+ }
193
+ }
194
+ }
195
+
196
+ // ── Private ────────────────────────────────────────────────────────────
197
+
198
+ private deliverResult(job: Job, attempt = 0): void {
199
+ if (this.acknowledgedJobs.has(job.id)) return;
200
+ if (!this.onJobComplete) return;
201
+
202
+ this.onJobComplete(job);
203
+
204
+ // Schedule retry with exponential backoff + jitter
205
+ const delay = Math.min(
206
+ DELIVERY_BASE_MS * Math.pow(2, attempt) + Math.random() * DELIVERY_JITTER_MS,
207
+ DELIVERY_MAX_MS,
208
+ );
209
+
210
+ const timer = setTimeout(() => {
211
+ this.deliveryTimers.delete(job.id);
212
+ if (!this.acknowledgedJobs.has(job.id)) {
213
+ this.deliverResult(job, attempt + 1);
214
+ }
215
+ }, delay);
216
+
217
+ this.deliveryTimers.set(job.id, timer);
218
+ }
219
+
220
+ private scheduleEviction(id: string): void {
221
+ const existing = this.evictionTimers.get(id);
222
+ if (existing) clearTimeout(existing);
223
+
224
+ const timer = setTimeout(() => {
225
+ this.evictionTimers.delete(id);
226
+ this.jobs.delete(id);
227
+ this.acknowledgedJobs.delete(id);
228
+ }, this.evictionMs);
229
+
230
+ this.evictionTimers.set(id, timer);
231
+ }
232
+
233
+ private evictOldest(): void {
234
+ let oldest: Job | undefined;
235
+ for (const job of this.jobs.values()) {
236
+ if (job.status !== "running") {
237
+ if (!oldest || job.startTime < oldest.startTime) {
238
+ oldest = job;
239
+ }
240
+ }
241
+ }
242
+ if (oldest) {
243
+ const timer = this.evictionTimers.get(oldest.id);
244
+ if (timer) clearTimeout(timer);
245
+ this.evictionTimers.delete(oldest.id);
246
+ this.jobs.delete(oldest.id);
247
+ this.acknowledgedJobs.delete(oldest.id);
248
+ }
249
+ }
250
+ }
@@ -673,10 +673,11 @@ export class GitServiceImpl {
673
673
  try {
674
674
  this.git(mergeArgs);
675
675
  } catch (mergeError) {
676
- // Check if conflicts are limited to runtime files we can auto-resolve (#189)
676
+ // Check if conflicts can be auto-resolved (#189, #218)
677
677
  const conflicted = this.git(["diff", "--name-only", "--diff-filter=U"], { allowFailure: true });
678
678
  if (conflicted) {
679
679
  const conflictedFiles = conflicted.split("\n").filter(Boolean);
680
+ const allGsd = conflictedFiles.every(f => f.startsWith(".gsd/"));
680
681
  const allRuntime = conflictedFiles.every(f =>
681
682
  RUNTIME_EXCLUSION_PATHS.some(excl => f.startsWith(excl.replace(/\/$/, ""))),
682
683
  );
@@ -688,12 +689,21 @@ export class GitServiceImpl {
688
689
  }
689
690
  this.git(["add", "-A"], { allowFailure: true });
690
691
  // Don't throw — let the merge proceed
692
+ } else if (allGsd) {
693
+ // Non-runtime .gsd/ conflicts (DECISIONS.md, REQUIREMENTS.md, ROADMAP.md, etc.):
694
+ // The slice branch has the authoritative .gsd/ state since the LLM just finished
695
+ // updating these artifacts during complete-slice. Take theirs (the slice branch).
696
+ for (const f of conflictedFiles) {
697
+ this.git(["checkout", "--theirs", "--", f], { allowFailure: true });
698
+ }
699
+ this.git(["add", "-A"], { allowFailure: true });
700
+ // Don't throw — let the merge proceed
691
701
  } else {
692
- // Non-runtime conflicts: reset and throw as before
702
+ // Non-.gsd/ conflicts: reset and throw as before
693
703
  this.git(["reset", "--hard", "HEAD"], { allowFailure: true });
694
704
  const msg = mergeError instanceof Error ? mergeError.message : String(mergeError);
695
705
  throw new Error(
696
- `${strategy === "merge" ? "Merge" : "Squash-merge"} of "${branch}" into "${mainBranch}" failed with conflicts. ` +
706
+ `${strategy === "merge" ? "Merge" : "Squash-merge"} of "${branch}" into "${mainBranch}" failed with conflicts in non-.gsd/ files. ` +
697
707
  `Working tree has been reset to a clean state. ` +
698
708
  `Resolve manually: git checkout ${mainBranch} && git merge ${strategy === "merge" ? "--no-ff" : "--squash"} ${branch}\n` +
699
709
  `Original error: ${msg}`,
@@ -127,8 +127,9 @@ Use the lightest sufficient tool first.
127
127
  - Broad unfamiliar subsystem mapping -> `subagent` with `scout`
128
128
  - Library, package, or framework truth -> `resolve_library` then `get_library_docs`
129
129
  - Current external facts -> `search-the-web` + `fetch_page`, or `search_and_read` for one-call extraction
130
- - Long-running commands (servers, watchers, builds) -> `bg_shell` with `start` + `wait_for_ready`
130
+ - Long-running processes (servers, watchers, persistent daemons) -> `bg_shell` with `start` + `wait_for_ready`
131
131
  - Background process status -> `bg_shell` with `digest` (not `output`). Token budget: `digest` (~30 tokens) < `highlights` (~100) < `output` (~2000).
132
+ - One-shot commands where you want the result delivered back (builds, tests, installs) -> `async_bash`; result is pushed to you automatically when the command exits.
132
133
  - Secrets -> `secure_env_collect`
133
134
 
134
135
  ### Ask vs infer
@@ -161,7 +162,9 @@ Fix the root cause, not symptoms. When applying a temporary mitigation, label it
161
162
 
162
163
  ### Background processes
163
164
 
164
- Use `bg_shell` for anything long-running. Set `type:'server'` + `ready_port` for dev servers, `group:'name'` for related processes. Use `wait_for_ready` instead of polling. Use `digest` for status checks, `highlights` for significant output, `output` only when debugging. Use `send_and_wait` for interactive CLIs. Kill processes when done.
165
+ Use `bg_shell` for persistent processes — servers, watchers, anything that keeps running. Set `type:'server'` + `ready_port` for dev servers, `group:'name'` for related processes. Use `wait_for_ready` instead of polling. Use `digest` for status checks, `highlights` for significant output, `output` only when debugging. Use `send_and_wait` for interactive CLIs. Kill processes when done.
166
+
167
+ Use `async_bash` for one-shot commands (builds, tests, installs) where you want the output delivered back automatically. Result arrives as a follow-up message when the command exits — no polling needed. Use `await_job` to explicitly wait for a specific job, `cancel_job` to stop one, `/jobs` to see what's running.
165
168
 
166
169
  ### Web behavior
167
170
 
@@ -953,6 +953,42 @@ async function main(): Promise<void> {
953
953
  rmSync(repo, { recursive: true, force: true });
954
954
  }
955
955
 
956
+ // ─── mergeSliceToMain: auto-resolve .gsd/ planning artifact conflicts ──
957
+
958
+ console.log("\n=== mergeSliceToMain: auto-resolve .gsd/ planning conflicts ===");
959
+
960
+ {
961
+ const repo = initBranchTestRepo();
962
+ const svc = new GitServiceImpl(repo);
963
+
964
+ // Create a .gsd/ planning artifact on main (simulates reassess-roadmap)
965
+ createFile(repo, ".gsd/DECISIONS.md", "# Decisions\n\n- D001: Original decision\n");
966
+ run("git add -A", repo);
967
+ run("git commit -m 'add decisions on main'", repo);
968
+
969
+ // Create slice branch and modify the same .gsd/ file differently
970
+ svc.ensureSliceBranch("M001", "S01");
971
+ createFile(repo, ".gsd/DECISIONS.md", "# Decisions\n\n- D001: Original decision\n- D002: New decision from slice\n");
972
+ createFile(repo, "src/feature.ts", "export const x = 1;");
973
+ run("git add -A", repo);
974
+ run("git commit -m 'slice work with .gsd/ changes'", repo);
975
+
976
+ // Back on main, modify the same .gsd/ file to create a conflict
977
+ svc.switchToMain();
978
+ createFile(repo, ".gsd/DECISIONS.md", "# Decisions\n\n- D001: Updated decision on main\n");
979
+ run("git add -A", repo);
980
+ run("git commit -m 'update decisions on main'", repo);
981
+
982
+ // Merge should auto-resolve .gsd/ conflicts by taking theirs (slice branch)
983
+ const result = svc.mergeSliceToMain("M001", "S01", "Feature with .gsd/ conflicts");
984
+ assertEq(result.deletedBranch, true, ".gsd/ conflict auto-resolved: branch deleted");
985
+
986
+ // Verify the merge succeeded and src file is present
987
+ assert(existsSync(join(repo, "src/feature.ts")), ".gsd/ conflict auto-resolved: src file merged");
988
+
989
+ rmSync(repo, { recursive: true, force: true });
990
+ }
991
+
956
992
  // ═══════════════════════════════════════════════════════════════════════
957
993
  // S05: Enhanced features — merge guards, snapshots, auto-push, rich commits
958
994
  // ═══════════════════════════════════════════════════════════════════════