gentle-pi 2.3.0 → 2.5.0

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 (153) hide show
  1. package/README.md +195 -11
  2. package/assets/agents/gentle-ai-worker.md +9 -0
  3. package/assets/agents/sdd-explore.md +1 -0
  4. package/assets/orchestrator-delegation.md +21 -10
  5. package/assets/orchestrator.md +8 -12
  6. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
  7. package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/README.md +10 -0
  8. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/manifest.json +74 -0
  9. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/orchestration/pi.md +53 -0
  10. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/schemas/targeted-validator.schema.json +1 -0
  11. package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-capabilities.baseline.json +9 -2
  12. package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-roles.baseline.json +2 -2
  13. package/docs/delegated-verification.md +25 -0
  14. package/docs/review-integration.md +1 -1
  15. package/docs/telemetry.md +38 -0
  16. package/extensions/ask-user-choice.ts +26 -20
  17. package/extensions/codegraph-tools.ts +94 -5
  18. package/extensions/gentle-agents.ts +588 -0
  19. package/extensions/gentle-ai.ts +1421 -143
  20. package/extensions/gentle-shell.ts +547 -0
  21. package/extensions/gentle-todo.ts +199 -0
  22. package/extensions/quiet-tools.ts +1 -1
  23. package/lib/agent-home.ts +8 -0
  24. package/lib/agents-config.ts +318 -0
  25. package/lib/agents-history.ts +80 -0
  26. package/lib/agents-protocol.ts +429 -0
  27. package/lib/agents-runner.ts +490 -0
  28. package/lib/agents-transcript.ts +87 -0
  29. package/lib/agents-view.ts +557 -0
  30. package/lib/agents-widget.ts +222 -0
  31. package/lib/gentle-ai-renderer.ts +142 -26
  32. package/lib/native-choice-list.ts +194 -0
  33. package/lib/native-fullscreen-interaction.ts +47 -0
  34. package/lib/native-pointer-region.ts +164 -0
  35. package/lib/native-review-cli.ts +103 -12
  36. package/lib/provider-contract-bundle.ts +88 -6
  37. package/lib/review-candidate-view-owner.ts +177 -0
  38. package/lib/review-candidate-view.ts +127 -35
  39. package/lib/review-consent-ui.ts +65 -0
  40. package/lib/review-host-relay.ts +146 -60
  41. package/lib/review-integration-v2.ts +92 -13
  42. package/lib/review-last-event-controller.ts +1 -0
  43. package/lib/review-relay-contract.ts +11 -0
  44. package/lib/review-repository.ts +2 -2
  45. package/lib/review-risk-assessment.ts +339 -0
  46. package/lib/review-session-standing-permission-ipc.ts +309 -0
  47. package/lib/review-session-standing-permission.ts +219 -0
  48. package/lib/sdd-preflight.ts +2 -2
  49. package/lib/shell-bar.ts +138 -0
  50. package/lib/shell-card.ts +136 -0
  51. package/lib/shell-changes-view.ts +205 -0
  52. package/lib/shell-changes.ts +210 -0
  53. package/lib/shell-gauge.ts +40 -0
  54. package/lib/shell-prompt.ts +119 -0
  55. package/lib/shell-todo.ts +280 -0
  56. package/lib/shell-usage-view.ts +76 -0
  57. package/lib/shell-usage.ts +246 -0
  58. package/lib/telemetry-trigger.ts +151 -0
  59. package/package.json +4 -4
  60. package/runtime/native-review-cli.mjs +102 -11
  61. package/runtime/review-integration-v2.mjs +92 -13
  62. package/runtime/review-relay-contract.mjs +11 -0
  63. package/runtime/review-risk-assessment.mjs +340 -0
  64. package/runtime/telemetry-trigger.mjs +152 -0
  65. package/scripts/build-runtime-modules.mjs +2 -0
  66. package/scripts/gentle-ai-installer.mjs +10 -10
  67. package/scripts/test-packed-runner.mjs +22 -0
  68. package/scripts/verify-package-files.mjs +18 -13
  69. package/skills/_shared/review-ledger-contract.md +9 -1
  70. package/skills/issue-creation/SKILL.md +53 -93
  71. package/tests/agents-config.test.ts +143 -0
  72. package/tests/agents-fake-child.ts +52 -0
  73. package/tests/agents-history.test.ts +54 -0
  74. package/tests/agents-protocol.test.ts +153 -0
  75. package/tests/agents-runner-process.test.ts +111 -0
  76. package/tests/agents-runner.test.ts +402 -0
  77. package/tests/agents-transcript.test.ts +30 -0
  78. package/tests/agents-view.test.ts +274 -0
  79. package/tests/agents-widget.test.ts +111 -0
  80. package/tests/ask-user-choice.test.ts +157 -3
  81. package/tests/codegraph-tools.test.ts +110 -1
  82. package/tests/devbinary/native-review-parity.devtest.ts +108 -0
  83. package/tests/fixtures/agents-process-child.mjs +23 -0
  84. package/tests/fixtures/provider-contract-bundle/v1.2.0/README.md +22 -0
  85. package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/manifest.json +11 -2
  86. package/tests/fixtures/provider-contract-bundle/v1.2.0/orchestration/pi.md +97 -0
  87. package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/lens.schema.json +16 -0
  88. package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/refuter.schema.json +1 -0
  89. package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/lens.json +1 -0
  90. package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/refuter.json +1 -0
  91. package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/targeted-validator.json +1 -0
  92. package/tests/gentle-agents.test.ts +741 -0
  93. package/tests/gentle-ai-binary.test.ts +1 -1
  94. package/tests/gentle-ai-installer.test.ts +47 -47
  95. package/tests/gentle-ai-renderer.test.ts +65 -0
  96. package/tests/gentle-ai.test.ts +31 -14
  97. package/tests/gentle-card-text.ts +35 -0
  98. package/tests/gentle-shell.test.ts +527 -0
  99. package/tests/gentle-todo.test.ts +182 -0
  100. package/tests/issue-creation-skill.test.ts +103 -0
  101. package/tests/native-choice-list.test.ts +202 -0
  102. package/tests/native-fullscreen-interaction.test.ts +125 -0
  103. package/tests/native-pointer-region.test.ts +245 -0
  104. package/tests/native-review-capability-contract.test.ts +33 -1
  105. package/tests/native-review-cli.test.ts +40 -0
  106. package/tests/native-review-consent.test.ts +91 -0
  107. package/tests/native-review-parity-runtime.test.ts +8 -2
  108. package/tests/native-review-parity.test.ts +29 -22
  109. package/tests/orchestrator-budget.test.ts +71 -2
  110. package/tests/orchestrator-rdd-ownership.test.ts +10 -1
  111. package/tests/package-manifest.test.ts +134 -9
  112. package/tests/provider-contract-bundle.test.ts +76 -0
  113. package/tests/provider-contract-mirror.test.ts +19 -0
  114. package/tests/quiet-tool-rendering.test.ts +96 -37
  115. package/tests/rdd-aware-verification-contract.test.ts +216 -0
  116. package/tests/rdd-status-line.test.ts +286 -0
  117. package/tests/review-agent-end-preflight.test.ts +408 -0
  118. package/tests/review-candidate-view.test.ts +452 -6
  119. package/tests/review-contract-prompt.test.ts +142 -0
  120. package/tests/review-controller-native-recovery.test.ts +29 -4
  121. package/tests/review-controller-native-routing.test.ts +321 -4
  122. package/tests/review-controller-workspace-root.test.ts +45 -2
  123. package/tests/review-controller.test.ts +26 -1
  124. package/tests/review-host-relay-routing.test.ts +229 -11
  125. package/tests/review-host-relay.test.ts +195 -7
  126. package/tests/review-integration-v2-forward.test.ts +47 -0
  127. package/tests/review-integration-v2.test.ts +112 -0
  128. package/tests/review-last-event-closure.test.ts +7 -2
  129. package/tests/review-ledger-contract.test.ts +1 -1
  130. package/tests/review-relay-contract.test.ts +26 -0
  131. package/tests/review-repository.test.ts +28 -1
  132. package/tests/review-risk-assessment.test.ts +626 -0
  133. package/tests/review-session-standing-permission-controller.test.ts +608 -0
  134. package/tests/review-session-standing-permission-ipc.test.ts +233 -0
  135. package/tests/review-session-standing-permission-runtime.test.ts +212 -0
  136. package/tests/review-session-standing-permission.test.ts +126 -0
  137. package/tests/runtime-harness.mjs +1 -0
  138. package/tests/shell-bar.test.ts +176 -0
  139. package/tests/shell-card.test.ts +118 -0
  140. package/tests/shell-changes-view.test.ts +146 -0
  141. package/tests/shell-changes.test.ts +182 -0
  142. package/tests/shell-prompt.test.ts +118 -0
  143. package/tests/shell-todo.test.ts +170 -0
  144. package/tests/shell-usage-view.test.ts +62 -0
  145. package/tests/shell-usage.test.ts +197 -0
  146. package/tests/telemetry-trigger.test.ts +349 -0
  147. package/tests/writer-edit-surface-scope.test.ts +153 -17
  148. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/lens.schema.json +0 -0
  149. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/refuter.schema.json +0 -0
  150. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/lens.json +0 -0
  151. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/refuter.json +0 -0
  152. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/targeted-validator.json +0 -0
  153. /package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/schemas/targeted-validator.schema.json +0 -0
@@ -0,0 +1,490 @@
1
+ import type { Duplex, Readable, Writable } from "node:stream";
2
+ import { formatModelRef, type AgentDefinition, type AgentMode, type ModelRef } from "./agents-config.ts";
3
+ import { ParentStandingReviewPermissionBroker } from "./review-session-standing-permission-ipc.ts";
4
+ import { isFinished, normalizeRpcEvent, TASK_EVENT, TASK_STATUS, taskLabel, type AskRequest, type TaskRecord, type TaskStore } from "./agents-protocol.ts";
5
+
6
+ // Gentle Agents runner. Every subagent is its own `pi --mode rpc` process:
7
+ // the host never runs subagent work on the TUI thread. It writes JSON
8
+ // commands, reads JSON lines, applies deltas to the store, answers dialogs,
9
+ // and enforces an inactivity watchdog per task.
10
+
11
+ export interface ChildLike {
12
+ pid: number | undefined;
13
+ stdin: Writable;
14
+ stdout: Readable;
15
+ stderr: Readable | null | undefined;
16
+ stdio?: Array<Duplex | null | undefined>;
17
+ kill(signal?: NodeJS.Signals): boolean;
18
+ on(event: "exit", listener: (code: number | null, signal: NodeJS.Signals | null) => void): unknown;
19
+ on(event: "error", listener: (error: Error) => void): unknown;
20
+ }
21
+
22
+ export interface SpawnOptions {
23
+ cwd: string;
24
+ env: NodeJS.ProcessEnv;
25
+ detached?: boolean;
26
+ stdio?: Array<"pipe" | "ignore" | "inherit">;
27
+ }
28
+
29
+ export type Spawn = (command: string, args: string[], options: SpawnOptions) => ChildLike;
30
+
31
+ export interface ProcessControl {
32
+ platform: NodeJS.Platform;
33
+ kill(pid: number, signal: NodeJS.Signals | 0): void;
34
+ }
35
+
36
+ export interface PiCommand {
37
+ command: string;
38
+ args: string[];
39
+ }
40
+
41
+ export interface RunnerDeps {
42
+ spawn: Spawn;
43
+ now(): number;
44
+ schedule(fn: () => void, ms: number): () => void;
45
+ pi: PiCommand;
46
+ process?: ProcessControl;
47
+ }
48
+
49
+ export interface RunnerLimits {
50
+ maxConcurrency: number;
51
+ stallTimeoutMs: number;
52
+ }
53
+
54
+ export interface AskAnswer {
55
+ value?: string;
56
+ confirmed?: boolean;
57
+ cancelled?: boolean;
58
+ }
59
+
60
+ export interface RunnerHooks {
61
+ askUser(taskId: string, request: AskRequest, raw: Record<string, unknown>): Promise<AskAnswer>;
62
+ onFinish?(task: TaskRecord): void;
63
+ }
64
+
65
+ export interface TaskRequest {
66
+ agent: AgentDefinition;
67
+ prompt: string;
68
+ label: string | undefined;
69
+ context: string | undefined;
70
+ mode: AgentMode;
71
+ cwd: string;
72
+ parentSessionId: string;
73
+ model: ModelRef | undefined;
74
+ thinking: string | undefined;
75
+ sessionDir: string;
76
+ resumeSessionPath: string | undefined;
77
+ env: NodeJS.ProcessEnv;
78
+ // This closure stays only in the parent process. Its presence creates an
79
+ // inherited fd, never an environment boolean or model-visible permission.
80
+ authorizeParentStandingReviewPermission?: (repositoryIdentity: string) => boolean;
81
+ }
82
+
83
+ interface ProcessLike {
84
+ execPath: string;
85
+ argv: string[];
86
+ env: NodeJS.ProcessEnv;
87
+ }
88
+
89
+ interface Pending {
90
+ resolve(value: Record<string, unknown>): void;
91
+ }
92
+
93
+ interface LiveTask {
94
+ child: ChildLike;
95
+ pending: Map<string, Pending>;
96
+ cancelStall: () => void;
97
+ cancelGrace: () => void;
98
+ processGroup: number | undefined;
99
+ terminal: { status: TaskRecord["status"]; error: string | null } | undefined;
100
+ childExit: number | null | undefined;
101
+ cleanupDeadlineAt: number | undefined;
102
+ quarantined: boolean;
103
+ nextId: number;
104
+ permissionBroker?: ParentStandingReviewPermissionBroker;
105
+ }
106
+
107
+ const CHILD_MARKER = "GENTLE_PI_AGENTS_CHILD";
108
+ const DEFAULT_TOOLS: readonly string[] = [];
109
+ const TERMINATION_GRACE_MS = 250;
110
+ const GROUP_CONFIRM_MS = 25;
111
+ const GROUP_CONFIRM_DEADLINE_MS = 1_000;
112
+ const hostProcess: ProcessControl = { platform: process.platform, kill: (pid, signal) => process.kill(pid, signal) };
113
+
114
+ export function childArguments(request: TaskRequest): string[] {
115
+ const args = ["--mode", "rpc", "--session-dir", request.sessionDir];
116
+ if (request.resumeSessionPath) args.push("--session", request.resumeSessionPath);
117
+ if (request.model) args.push("--model", request.thinking ? `${formatModelRef(request.model)}:${request.thinking}` : formatModelRef(request.model));
118
+ else if (request.thinking) args.push("--thinking", request.thinking);
119
+ const tools = request.agent.tools.length > 0 ? request.agent.tools : DEFAULT_TOOLS;
120
+ if (tools.length > 0) args.push("--tools", tools.join(","));
121
+ if (request.agent.instructions.length > 0) args.push("--append-system-prompt", request.agent.instructions);
122
+ return args;
123
+ }
124
+
125
+ // The child is the same pi that is running us: node plus its cli entry.
126
+ // GENTLE_PI_AGENTS_PI overrides it with a command line.
127
+ export function piCommand(proc: ProcessLike = process): PiCommand {
128
+ const override = proc.env.GENTLE_PI_AGENTS_PI?.trim();
129
+ if (override) {
130
+ const [command, ...args] = override.split(/\s+/);
131
+ return { command, args };
132
+ }
133
+ const entry = proc.argv[1];
134
+ if (entry && /(^|[\\/])cli\.js$/.test(entry)) return { command: proc.execPath, args: [entry] };
135
+ return { command: "pi", args: [] };
136
+ }
137
+
138
+ // RPC framing is strict JSONL: LF only, optional CR. Lines that do not parse
139
+ // are dropped (pi's own parse errors arrive as responses anyway).
140
+ export class JsonLines {
141
+ private buffer = "";
142
+ private readonly onValue: (value: unknown) => void;
143
+
144
+ constructor(onValue: (value: unknown) => void) {
145
+ this.onValue = onValue;
146
+ }
147
+
148
+ push(chunk: string): void {
149
+ this.buffer += chunk;
150
+ const lines = this.buffer.split("\n");
151
+ this.buffer = lines.pop() ?? "";
152
+ for (const raw of lines) {
153
+ const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
154
+ if (line.length === 0) continue;
155
+ try {
156
+ this.onValue(JSON.parse(line));
157
+ } catch {
158
+ // not JSON: ignore
159
+ }
160
+ }
161
+ }
162
+ }
163
+
164
+ export function promptText(request: TaskRequest): string {
165
+ return request.context ? `${request.prompt}\n\n## Context\n${request.context}` : request.prompt;
166
+ }
167
+
168
+ export class AgentRunner {
169
+ private readonly store: TaskStore;
170
+ private readonly limits: RunnerLimits;
171
+ private readonly deps: RunnerDeps;
172
+ private readonly hooks: RunnerHooks;
173
+ private readonly processControl: ProcessControl;
174
+ private readonly queue: Array<{ task: TaskRecord; request: TaskRequest }> = [];
175
+ private readonly live = new Map<string, LiveTask>();
176
+ private readonly waiters = new Map<string, Array<(task: TaskRecord) => void>>();
177
+ private counter = 0;
178
+
179
+ constructor(store: TaskStore, limits: RunnerLimits, deps: RunnerDeps, hooks: RunnerHooks) {
180
+ this.store = store;
181
+ this.limits = limits;
182
+ this.deps = deps;
183
+ this.hooks = hooks;
184
+ this.processControl = deps.process ?? hostProcess;
185
+ }
186
+
187
+ run(request: TaskRequest): TaskRecord {
188
+ const now = this.deps.now();
189
+ this.counter += 1;
190
+ const task: TaskRecord = {
191
+ id: `${now.toString(36)}-${this.counter.toString(36)}-${Math.random().toString(36).slice(2, 6)}`,
192
+ agent: request.agent.name,
193
+ mode: request.mode,
194
+ prompt: request.prompt,
195
+ label: taskLabel(request.prompt, request.label),
196
+ cwd: request.cwd,
197
+ parentSessionId: request.parentSessionId,
198
+ status: TASK_STATUS.QUEUED,
199
+ createdAt: now,
200
+ startedAt: null,
201
+ endedAt: null,
202
+ model: formatModelRef(request.model),
203
+ thinking: request.thinking,
204
+ sessionPath: request.resumeSessionPath ?? null,
205
+ error: null,
206
+ result: null,
207
+ lastStep: "queued",
208
+ lastActivityAt: now,
209
+ turns: 0,
210
+ toolCalls: 0,
211
+ tokens: 0,
212
+ cost: 0,
213
+ };
214
+ this.store.add(task);
215
+ this.queue.push({ task, request });
216
+ queueMicrotask(() => this.pump());
217
+ return task;
218
+ }
219
+
220
+ waitFor(id: string): Promise<TaskRecord> {
221
+ const current = this.store.get(id);
222
+ if (!current) return Promise.reject(new Error(`no task ${id}`));
223
+ if (isFinished(current.status)) return Promise.resolve(current);
224
+ return new Promise((resolve) => {
225
+ const list = this.waiters.get(id) ?? [];
226
+ list.push(resolve);
227
+ this.waiters.set(id, list);
228
+ });
229
+ }
230
+
231
+ cancel(id: string): boolean {
232
+ const queued = this.queue.findIndex((entry) => entry.task.id === id);
233
+ if (queued >= 0) {
234
+ this.queue.splice(queued, 1);
235
+ this.finish(id, TASK_STATUS.CANCELLED, "cancelled before start");
236
+ return true;
237
+ }
238
+ if (!this.live.has(id)) return false;
239
+ this.requestStop(id, TASK_STATUS.CANCELLED, "cancelled", true);
240
+ return true;
241
+ }
242
+
243
+ cancelAll(): number {
244
+ const ids = [...this.queue.map((entry) => entry.task.id), ...this.live.keys()];
245
+ return ids.filter((id) => this.cancel(id)).length;
246
+ }
247
+
248
+ steer(id: string, message: string): boolean {
249
+ if (!this.live.has(id)) return false;
250
+ void this.send(id, { type: "steer", message });
251
+ this.store.apply(id, { type: TASK_EVENT.NOTE, text: `steered: ${message}` }, this.deps.now());
252
+ return true;
253
+ }
254
+
255
+ private pump(): void {
256
+ while (this.live.size < this.limits.maxConcurrency && this.queue.length > 0) {
257
+ const entry = this.queue.shift();
258
+ if (entry) this.launch(entry.task.id, entry.request);
259
+ }
260
+ }
261
+
262
+ // A child that cannot start (missing pi, bad cwd) fails only its task:
263
+ // spawn exceptions and process errors settle without uncaught host errors.
264
+ private launch(id: string, request: TaskRequest): void {
265
+ const detached = this.processControl.platform !== "win32";
266
+ const hasParentPermissionChannel = request.authorizeParentStandingReviewPermission !== undefined;
267
+ const env = {
268
+ ...request.env,
269
+ [CHILD_MARKER]: "1",
270
+ ...(hasParentPermissionChannel ? { GENTLE_PI_AGENTS_PARENT_PERMISSION_FD: "3" } : {}),
271
+ };
272
+ let child: ChildLike;
273
+ try {
274
+ child = this.deps.spawn(this.deps.pi.command, [...this.deps.pi.args, ...childArguments(request)], {
275
+ cwd: request.cwd,
276
+ env,
277
+ detached,
278
+ ...(hasParentPermissionChannel ? { stdio: ["pipe", "pipe", "pipe", "pipe"] } : {}),
279
+ });
280
+ } catch (error) {
281
+ this.store.update(id, { status: TASK_STATUS.RUNNING, startedAt: this.deps.now(), lastStep: "starting" });
282
+ this.finish(id, TASK_STATUS.FAILED, `could not start pi: ${error instanceof Error ? error.message : String(error)}`);
283
+ return;
284
+ }
285
+ const processGroup = detached && typeof child.pid === "number" && child.pid > 0 ? child.pid : undefined;
286
+ const live: LiveTask = { child, pending: new Map(), cancelStall: () => {}, cancelGrace: () => {}, processGroup, terminal: undefined, childExit: undefined, cleanupDeadlineAt: undefined, quarantined: false, nextId: 0 };
287
+ this.live.set(id, live);
288
+ const permissionPipe = child.stdio?.[3];
289
+ if (hasParentPermissionChannel && permissionPipe !== undefined && permissionPipe !== null) {
290
+ live.permissionBroker = new ParentStandingReviewPermissionBroker(
291
+ { readable: permissionPipe, writable: permissionPipe },
292
+ (repositoryIdentity) => this.live.get(id) === live && !live.terminal && request.authorizeParentStandingReviewPermission?.(repositoryIdentity) === true,
293
+ );
294
+ }
295
+ this.store.update(id, { status: TASK_STATUS.RUNNING, startedAt: this.deps.now(), lastStep: "starting" });
296
+ child.on("error", (error) => this.childError(id, error));
297
+ child.stdin.on("error", () => {});
298
+ this.armStall(id, live);
299
+ const lines = new JsonLines((value) => this.receive(id, request, value));
300
+ child.stdout.setEncoding("utf8");
301
+ child.stdout.on("data", (chunk: string) => lines.push(chunk));
302
+ child.stderr?.on("data", () => {});
303
+ child.on("exit", (code) => this.exited(id, code));
304
+ void this.send(id, { type: "get_state" }).then((response) => {
305
+ const data = response.data as { sessionFile?: string } | undefined;
306
+ if (!live.terminal && this.live.get(id) === live && data?.sessionFile) this.store.update(id, { sessionPath: data.sessionFile });
307
+ });
308
+ void this.send(id, { type: "prompt", message: promptText(request) }).then((response) => {
309
+ if (response.success === false) this.requestStop(id, TASK_STATUS.FAILED, String(response.error ?? "prompt rejected"));
310
+ });
311
+ }
312
+
313
+ private armStall(id: string, live: LiveTask): void {
314
+ live.cancelStall();
315
+ live.cancelStall = this.deps.schedule(() => this.requestStop(id, TASK_STATUS.TIMED_OUT, `stalled for ${Math.round(this.limits.stallTimeoutMs / 60_000)} min`), this.limits.stallTimeoutMs);
316
+ }
317
+
318
+ private send(id: string, command: Record<string, unknown>): Promise<Record<string, unknown>> {
319
+ const live = this.live.get(id);
320
+ if (!live) return Promise.resolve({ success: false, error: "task is not running" });
321
+ live.nextId += 1;
322
+ const requestId = `r${live.nextId}`;
323
+ return new Promise((resolve) => {
324
+ live.pending.set(requestId, { resolve });
325
+ this.write(live, { id: requestId, ...command });
326
+ });
327
+ }
328
+
329
+ private write(live: LiveTask, payload: Record<string, unknown>): void {
330
+ try {
331
+ live.child.stdin.write(`${JSON.stringify(payload)}\n`);
332
+ } catch {
333
+ // the child is gone; the exit handler settles the task
334
+ }
335
+ }
336
+
337
+ private receive(id: string, request: TaskRequest, value: unknown): void {
338
+ const live = this.live.get(id);
339
+ if (!live || live.terminal || !value || typeof value !== "object") return;
340
+ const raw = value as Record<string, unknown>;
341
+ this.armStall(id, live);
342
+ if (raw.type === "response") {
343
+ const pending = typeof raw.id === "string" ? live.pending.get(raw.id) : undefined;
344
+ if (pending) {
345
+ live.pending.delete(raw.id as string);
346
+ pending.resolve(raw);
347
+ }
348
+ return;
349
+ }
350
+ for (const event of normalizeRpcEvent(raw)) {
351
+ this.store.apply(id, event, this.deps.now());
352
+ if (event.type === TASK_EVENT.ASK) void this.answer(id, request, live, event.request, raw);
353
+ if (event.type === TASK_EVENT.AGENT_SETTLED) {
354
+ const terminal = this.store.get(id);
355
+ if (terminal?.error) this.requestStop(id, TASK_STATUS.FAILED, terminal.error);
356
+ else if (terminal?.result) this.requestStop(id, TASK_STATUS.COMPLETED, null);
357
+ else this.requestStop(id, TASK_STATUS.FAILED, "assistant settled without a final report");
358
+ }
359
+ }
360
+ }
361
+
362
+ // Task-mode subagents may ask the human through the host; background ones
363
+ // get their dialog cancelled so they never block on nobody.
364
+ private async answer(id: string, request: TaskRequest, live: LiveTask, ask: AskRequest, raw: Record<string, unknown>): Promise<void> {
365
+ let answer: AskAnswer = { cancelled: true };
366
+ if (request.mode === "task") {
367
+ try {
368
+ answer = await this.hooks.askUser(id, ask, raw);
369
+ } catch {
370
+ answer = { cancelled: true };
371
+ }
372
+ }
373
+ if (this.live.get(id) !== live || live.terminal) return;
374
+ this.write(live, { type: "extension_ui_response", id: ask.id, ...answer });
375
+ const current = this.store.get(id);
376
+ if (current?.status === TASK_STATUS.WAITING) this.store.update(id, { status: TASK_STATUS.RUNNING, lastStep: answer.cancelled ? "question dismissed" : "answered" });
377
+ }
378
+
379
+ // POSIX children start detached, so their PID is the owned process-group ID.
380
+ // Windows uses ChildProcess.kill only: Node has no equivalent tree guarantee.
381
+ private signal(live: LiveTask, signal: NodeJS.Signals): void {
382
+ if (live.processGroup !== undefined) {
383
+ try {
384
+ this.processControl.kill(-live.processGroup, signal);
385
+ return;
386
+ } catch {
387
+ // The owned group is already gone; the child handle may still observe exit.
388
+ }
389
+ }
390
+ try {
391
+ live.child.kill(signal);
392
+ } catch {
393
+ // already gone
394
+ }
395
+ }
396
+
397
+ private requestStop(id: string, status: TaskRecord["status"], error: string | null, abort = false): void {
398
+ const live = this.live.get(id);
399
+ if (!live || live.terminal) return;
400
+ live.terminal = { status, error };
401
+ live.cleanupDeadlineAt = this.deps.now() + GROUP_CONFIRM_DEADLINE_MS;
402
+ live.permissionBroker?.close();
403
+ live.cancelStall();
404
+ if (abort) void this.send(id, { type: "abort" });
405
+ this.signal(live, "SIGTERM");
406
+ live.cancelGrace = this.deps.schedule(() => {
407
+ if (this.live.get(id) !== live) return;
408
+ this.signal(live, "SIGKILL");
409
+ this.confirmGroupExit(id, live);
410
+ }, TERMINATION_GRACE_MS);
411
+ }
412
+
413
+ private groupExists(live: LiveTask): boolean {
414
+ if (live.processGroup === undefined) return false;
415
+ try {
416
+ this.processControl.kill(-live.processGroup, 0);
417
+ return true;
418
+ } catch (error) {
419
+ return (error as NodeJS.ErrnoException).code !== "ESRCH";
420
+ }
421
+ }
422
+
423
+ private confirmGroupExit(id: string, live: LiveTask): void {
424
+ if (this.live.get(id) !== live) return;
425
+ if (this.groupExists(live)) {
426
+ if (this.deps.now() >= (live.cleanupDeadlineAt ?? 0)) {
427
+ live.cancelGrace();
428
+ live.quarantined = true;
429
+ this.finish(id, TASK_STATUS.FAILED, `process cleanup unconfirmed after ${GROUP_CONFIRM_DEADLINE_MS}ms; capacity quarantined`);
430
+ return;
431
+ }
432
+ live.cancelGrace = this.deps.schedule(() => this.confirmGroupExit(id, live), GROUP_CONFIRM_MS);
433
+ return;
434
+ }
435
+ if (live.childExit !== undefined) this.completeExit(id, live);
436
+ }
437
+
438
+ private childError(id: string, error: Error): void {
439
+ const live = this.live.get(id);
440
+ if (!live) return;
441
+ // Node leaves pid undefined when spawn failed; a live PID must still exit
442
+ // before its slot is released, even if its handle later emits an error.
443
+ if (live.child.pid !== undefined) {
444
+ this.requestStop(id, TASK_STATUS.FAILED, `pi process error: ${error.message}`);
445
+ return;
446
+ }
447
+ live.permissionBroker?.close();
448
+ live.cancelStall();
449
+ live.cancelGrace();
450
+ this.live.delete(id);
451
+ this.finish(id, TASK_STATUS.FAILED, `could not start pi: ${error.message}`);
452
+ }
453
+
454
+ private exited(id: string, code: number | null): void {
455
+ const live = this.live.get(id);
456
+ if (!live) return;
457
+ live.childExit = code;
458
+ if (this.groupExists(live)) {
459
+ if (!live.terminal) this.requestStop(id, TASK_STATUS.FAILED, `pi exited with code ${code ?? "unknown"} before agent_settled`);
460
+ return;
461
+ }
462
+ this.completeExit(id, live);
463
+ }
464
+
465
+ private completeExit(id: string, live: LiveTask): void {
466
+ live.permissionBroker?.close();
467
+ live.cancelStall();
468
+ live.cancelGrace();
469
+ this.live.delete(id);
470
+ // Quarantine already notified completion, but its retained slot is now free.
471
+ if (live.quarantined) {
472
+ queueMicrotask(() => this.pump());
473
+ return;
474
+ }
475
+ const terminal = live.terminal;
476
+ this.finish(id, terminal ? terminal.status : TASK_STATUS.FAILED, terminal ? terminal.error : `pi exited with code ${live.childExit ?? "unknown"} before agent_settled`);
477
+ }
478
+
479
+ private finish(id: string, status: TaskRecord["status"], error: string | null): void {
480
+ const current = this.store.get(id);
481
+ if (!current || isFinished(current.status)) return;
482
+ const finished = this.store.update(id, { status, endedAt: this.deps.now(), error, lastStep: error ?? "done" });
483
+ if (finished) {
484
+ this.hooks.onFinish?.(finished);
485
+ for (const resolve of this.waiters.get(id) ?? []) resolve(finished);
486
+ this.waiters.delete(id);
487
+ }
488
+ queueMicrotask(() => this.pump());
489
+ }
490
+ }
@@ -0,0 +1,87 @@
1
+ import { sanitizeTerminalText } from "./terminal-theme.ts";
2
+
3
+ // Gentle Agents transcript: a child's session JSONL rendered as markdown a
4
+ // human can read in an editor. Thinking is left out; tool output keeps a tail.
5
+
6
+ export interface TranscriptOptions {
7
+ title: string;
8
+ maxOutputLines: number;
9
+ }
10
+
11
+ interface Part {
12
+ type?: string;
13
+ text?: string;
14
+ name?: string;
15
+ arguments?: Record<string, unknown>;
16
+ }
17
+
18
+ interface Entry {
19
+ type?: string;
20
+ message?: { role?: string; content?: Part[] | string; toolName?: string; isError?: boolean };
21
+ }
22
+
23
+ const DEFAULT_OPTIONS: TranscriptOptions = { title: "Subagent transcript", maxOutputLines: 40 };
24
+
25
+ function clean(value: unknown): string {
26
+ return typeof value === "string" ? sanitizeTerminalText(value) : "";
27
+ }
28
+
29
+ function parts(content: Part[] | string | undefined): Part[] {
30
+ if (typeof content === "string") return [{ type: "text", text: content }];
31
+ return Array.isArray(content) ? content : [];
32
+ }
33
+
34
+ function argsSummary(args: Record<string, unknown> | undefined): string {
35
+ if (!args) return "";
36
+ const first = Object.values(args).find((value) => typeof value === "string") as string | undefined;
37
+ return first ? clean(first).replace(/\s+/g, " ").trim() : JSON.stringify(args);
38
+ }
39
+
40
+ function tail(text: string, max: number): string {
41
+ const lines = text.split("\n");
42
+ if (lines.length <= max) return text;
43
+ return [`… ${lines.length - max} earlier lines omitted`, ...lines.slice(-max)].join("\n");
44
+ }
45
+
46
+ function fence(text: string): string {
47
+ const ticks = /`{3,}/.test(text) ? "````" : "```";
48
+ return `${ticks}\n${text}\n${ticks}`;
49
+ }
50
+
51
+ export function sessionToMarkdown(jsonl: string, options: Partial<TranscriptOptions> = {}): string {
52
+ const { title, maxOutputLines } = { ...DEFAULT_OPTIONS, ...options };
53
+ const out: string[] = [`# ${title}`, ""];
54
+ for (const raw of jsonl.split("\n")) {
55
+ if (raw.trim().length === 0) continue;
56
+ let entry: Entry;
57
+ try {
58
+ entry = JSON.parse(raw) as Entry;
59
+ } catch {
60
+ continue;
61
+ }
62
+ if (entry.type !== "message" || !entry.message) continue;
63
+ const { role, content, toolName, isError } = entry.message;
64
+ if (role === "user") {
65
+ const text = parts(content).map((part) => clean(part.text)).filter((text) => text.length > 0).join("\n");
66
+ if (text) out.push("## User", "", text, "");
67
+ } else if (role === "assistant") {
68
+ let opened = false;
69
+ for (const part of parts(content)) {
70
+ if (part.type === "text" && clean(part.text).trim().length > 0) {
71
+ if (!opened) {
72
+ out.push("## Assistant", "");
73
+ opened = true;
74
+ }
75
+ out.push(clean(part.text).trim(), "");
76
+ } else if (part.type === "toolCall") {
77
+ out.push(`### ▸ ${clean(part.name) || "tool"} ${argsSummary(part.arguments)}`.trimEnd(), "");
78
+ }
79
+ }
80
+ } else if (role === "toolResult") {
81
+ const text = parts(content).map((part) => clean(part.text)).filter((text) => text.length > 0).join("\n");
82
+ const label = isError ? `${clean(toolName) || "tool"} error` : (clean(toolName) || "tool");
83
+ out.push(`<!-- ${label} -->`, fence(tail(text.length > 0 ? text : "(no output)", maxOutputLines)), "");
84
+ }
85
+ }
86
+ return `${out.join("\n").trimEnd()}\n`;
87
+ }