pi-thread-engine 0.4.7 → 0.4.9

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.
@@ -0,0 +1,68 @@
1
+ # Python `hello_world()` Utility
2
+
3
+ > A lightweight Python entry point for pi-thread-engine — useful for
4
+ > Pi extension scripts that prefer Python over TypeScript.
5
+
6
+ ## Purpose
7
+
8
+ Mirrors the TypeScript `helloWorld()` function (`src/hello.ts`) as a
9
+ simple verification utility. Calling `hello_world()` returns the
10
+ canonical greeting string `"Hello, World!"` with zero side effects.
11
+
12
+ ## Usage
13
+
14
+ ### Import
15
+
16
+ ```python
17
+ from scripts.hello import hello_world
18
+
19
+ greeting = hello_world()
20
+ print(greeting) # Hello, World!
21
+ ```
22
+
23
+ ### CLI invocation
24
+
25
+ ```bash
26
+ python scripts/hello.py
27
+ # Hello, World!
28
+ ```
29
+
30
+ ## Tests
31
+
32
+ ```bash
33
+ python -m unittest scripts/test_hello.py -v
34
+ ```
35
+
36
+ Expected output:
37
+
38
+ ```
39
+ test_returns_hello_world_string (scripts.test_hello.TestHelloWorld.test_returns_hello_world_string)
40
+ hello_world() should return exactly 'Hello, World!'. ... ok
41
+ test_returns_string_type (scripts.test_hello.TestHelloWorld.test_returns_string_type)
42
+ hello_world() should return a str instance. ... ok
43
+
44
+ ----------------------------------------------------------------------
45
+ Ran 2 tests in 0.000s
46
+
47
+ OK
48
+ ```
49
+
50
+ ## Implementation
51
+
52
+ | Aspect | Detail |
53
+ |--------|--------|
54
+ | **File** | `scripts/hello.py` |
55
+ | **Function** | `hello_world() -> str` |
56
+ | **Return value** | `"Hello, World!"` |
57
+ | **Framework** | Python 3 (stdlib only) |
58
+ | **Test framework** | `unittest` (stdlib) |
59
+ | **Test file** | `scripts/test_hello.py` |
60
+
61
+ ## Integration
62
+
63
+ The Python test is integrated into the project's test suite via
64
+ `scripts/test.sh` (step 4). Run the full suite with:
65
+
66
+ ```bash
67
+ bash scripts/test.sh
68
+ ```
@@ -1,4 +1,4 @@
1
- import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
1
+ import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
2
2
  import { Type, type TUnsafe } from "@sinclair/typebox";
3
3
  import { Text } from "@mariozechner/pi-tui";
4
4
 
@@ -21,6 +21,7 @@ import type { Thread, ThreadType, Story, StoryPhase } from "../src/core/types.js
21
21
  import { writeFileSync, readFileSync, existsSync, mkdirSync } from "fs";
22
22
  import { join, resolve } from "path";
23
23
  import { createWorktree, removeWorktree, listWorktrees, pushWorktreeChanges, cleanupAll, isGitRepo, branchName, findRepoRoot } from "../src/core/worktree.js";
24
+ import { helloWorld } from "../src/hello-world.js";
24
25
 
25
26
  // ── Export helper ───────────────────────────────────────────
26
27
  function exportThread(id: string, r: ThreadRegistry, cwd: string): string | null {
@@ -55,7 +56,11 @@ export default function (pi: ExtensionAPI) {
55
56
  const threads = registry.all().filter((t) => t.state !== "killed");
56
57
  const stories = registry.allStories();
57
58
  if (threads.length > 0 || stories.length > 0) {
58
- pi.appendEntry("pi-threads-state", { threads, stories, timestamp: Date.now() });
59
+ try {
60
+ pi.appendEntry("pi-threads-state", { threads, stories, timestamp: Date.now() });
61
+ } catch {
62
+ // Extension contexts can go stale after session replacement; persistence is best-effort.
63
+ }
59
64
  }
60
65
  }
61
66
 
@@ -81,8 +86,10 @@ export default function (pi: ExtensionAPI) {
81
86
 
82
87
  // ── Status bar ───────────────────────────────────────────────
83
88
 
89
+ let clearStatusHandler: (() => void) | undefined;
84
90
  pi.on("session_start", async (_event, ctx) => {
85
- registry.on(() => {
91
+ clearStatusHandler?.();
92
+ clearStatusHandler = registry.on(() => {
86
93
  const running = registry.byState("running");
87
94
  const stories = registry.allStories().filter((s) => s.state === "executing" || s.state === "planning");
88
95
  const parts: string[] = [];
@@ -99,7 +106,11 @@ export default function (pi: ExtensionAPI) {
99
106
  );
100
107
  }
101
108
 
102
- ctx.ui.setStatus("pi-threads", parts.length > 0 ? parts.join(" ") : undefined);
109
+ try {
110
+ ctx.ui.setStatus("pi-threads", parts.length > 0 ? parts.join(" ") : undefined);
111
+ } catch {
112
+ // Status updates are best-effort; command/session contexts may go stale.
113
+ }
103
114
  });
104
115
  });
105
116
 
@@ -125,6 +136,7 @@ export default function (pi: ExtensionAPI) {
125
136
  return tasks;
126
137
  }
127
138
 
139
+
128
140
  function stateIcon(state: string): string {
129
141
  switch (state) {
130
142
  case "running": return "⟳";
@@ -138,16 +150,54 @@ export default function (pi: ExtensionAPI) {
138
150
 
139
151
  function typeIcon(type: string): string {
140
152
  switch (type) {
153
+ case "base": return "•";
141
154
  case "parallel": return "⫘";
142
155
  case "chained": return "⟶";
143
156
  case "fusion": return "⊕";
144
157
  case "meta": return "◎";
145
158
  case "long": return "∞";
146
159
  case "zero": return "⊘";
160
+ case "worktree": return "🌳";
161
+ case "plan": return "⌁";
162
+ case "scheduled": return "⏰";
147
163
  default: return "·";
148
164
  }
149
165
  }
150
166
 
167
+ async function openDashboard(ctx: ExtensionCommandContext) {
168
+ await ctx.ui.custom<void>((tui, theme, _kb, done) => {
169
+ const dashboard = createDashboard(
170
+ registry,
171
+ theme,
172
+ () => done(),
173
+ (id) => {
174
+ registry.kill(id);
175
+ ctx.ui.notify(`Killed ${id}`, "warning");
176
+ tui.requestRender();
177
+ },
178
+ (id) => {
179
+ const t = registry.get(id);
180
+ if (t) {
181
+ const preview = t.tasks.map((tk) => `${tk.id}: ${tk.result?.slice(0, 100) ?? tk.error ?? "(pending)"}`).join("\n");
182
+ ctx.ui.notify(`Thread ${id} results:\n${preview}`, "info");
183
+ }
184
+ },
185
+ (id, message) => {
186
+ executor.injectReply(id, message);
187
+ ctx.ui.notify(`Replied to ${id}: ${message.slice(0, 50)}...`, "info");
188
+ tui.requestRender();
189
+ },
190
+ (id) => { const p = exportThread(id, registry, ctx.cwd); if (p) ctx.ui.notify(`Exported to ${p}`, "info"); }
191
+ );
192
+
193
+ return {
194
+ render: (w: number) => dashboard.render(w),
195
+ invalidate: () => dashboard.invalidate(),
196
+ handleInput: (data: string) => { dashboard.handleInput(data); tui.requestRender(); },
197
+ };
198
+ });
199
+ }
200
+
151
201
  // ── /threads — unified TUI dashboard ─────────────────────────
152
202
 
153
203
  pi.registerCommand("threads", {
@@ -241,69 +291,21 @@ export default function (pi: ExtensionAPI) {
241
291
  }
242
292
  const ts = new Date().toISOString().slice(0, 19).replace(/[T:]/g, "-");
243
293
  const fn = "threads-" + ts + ".md";
244
- const outPath = require("path").join(ctx.cwd, fn);
245
- require("fs").writeFileSync(outPath, md.join("\n"), "utf8");
294
+ const outPath = join(ctx.cwd, fn);
295
+ writeFileSync(outPath, md.join("\n"), "utf8");
246
296
  ctx.ui.notify("Exported to " + outPath, "info");
247
297
  return;
248
298
  }
249
299
 
250
- // Default: open interactive TUI dashboard
251
- // ── /agents alias (Claude-style muscle memory) ──────────────
252
- pi.registerCommand("agents", {
253
- description: "Alias for /threads — Claude-style Agent View dashboard",
254
- handler: async (a, c) => {
255
- // Forward to /threads
256
- await ctx.ui.custom<void>((tui, theme, _kb, done) => {
257
- const dashboard = createDashboard(
258
- registry,
259
- theme,
260
- () => done(),
261
- (id) => { registry.kill(id); ctx.ui.notify(`Killed ${id}`, "warning"); tui.requestRender(); },
262
- (id) => { const t = registry.get(id); if (t) { ctx.ui.notify(`Thread ${id}: ${t.tasks.map((tk) => `${tk.id}: ${tk.result?.slice(0,100) ?? tk.error ?? "(pending)"}`).join("\n")}`, "info"); } },
263
- (id, message) => { executor.injectReply(id, message); ctx.ui.notify(`Replied to ${id}: ${message.slice(0, 50)}...`, "info"); tui.requestRender(); },
264
- (id) => { const p = exportThread(id, registry, ctx.cwd); if (p) ctx.ui.notify(`Exported to ${p}`, "info"); }
265
- );
266
- return { render: (w: number) => dashboard.render(w), invalidate: () => dashboard.invalidate(), handleInput: (data: string) => { dashboard.handleInput(data); tui.requestRender(); } };
267
- });
268
- },
269
- });
270
- // ── End /agents alias ───────────────────────────────────────
271
-
272
- await ctx.ui.custom<void>((tui, theme, _kb, done) => {
273
- const dashboard = createDashboard(
274
- registry,
275
- theme,
276
- () => done(),
277
- (id) => {
278
- registry.kill(id);
279
- ctx.ui.notify(`Killed ${id}`, "warning");
280
- tui.requestRender();
281
- },
282
- (id) => {
283
- const t = registry.get(id);
284
- if (t) {
285
- const preview = t.tasks.map((tk) => `${tk.id}: ${tk.result?.slice(0, 100) ?? tk.error ?? "(pending)"}`).join("\n");
286
- ctx.ui.notify(`Thread ${id} results:\n${preview}`, "info");
287
- }
288
- },
289
- (id, message) => {
290
- // Inline reply — send message to blocked thread
291
- executor.injectReply(id, message);
292
- ctx.ui.notify(`Replied to ${id}: ${message.slice(0, 50)}...`, "info");
293
- tui.requestRender();
294
- },
295
- (id) => { const p = exportThread(id, registry, ctx.cwd); if (p) ctx.ui.notify(`Exported to ${p}`, "info"); }
296
- );
297
-
298
- return {
299
- render: (w: number) => dashboard.render(w),
300
- invalidate: () => dashboard.invalidate(),
301
- handleInput: (data: string) => { dashboard.handleInput(data); tui.requestRender(); },
302
- };
303
- });
300
+ await openDashboard(ctx);
304
301
  },
305
302
  });
306
303
 
304
+ pi.registerCommand("agents", {
305
+ description: "Alias for /threads — Claude-style Agent View dashboard",
306
+ handler: async (_args, ctx) => openDashboard(ctx),
307
+ });
308
+
307
309
  // ── /pthread — parallel via subagent ─────────────────────────
308
310
 
309
311
  pi.registerCommand("pthread", {
@@ -321,19 +323,24 @@ export default function (pi: ExtensionAPI) {
321
323
  },
322
324
  });
323
325
 
324
- // ── /cthread — chained via subagent ──────────────────────────
326
+ // ── /cthread — chained with human checkpoints ─────────────────
325
327
 
326
328
  pi.registerCommand("cthread", {
327
- description: 'C-Thread: sequential phases via subagent chain. Usage: /cthread "phase 1" "phase 2"',
329
+ description: 'C-Thread: sequential phases with human checkpoints. Usage: /cthread "phase 1" "phase 2"',
328
330
  handler: async (args, ctx) => {
329
331
  if (!args?.trim()) { ctx.ui.notify('Usage: /cthread "phase 1" "phase 2"', "error"); return; }
330
332
  const phases = parseTaskArgs(args);
331
333
  if (phases.length < 2) { ctx.ui.notify("Need at least 2 phases", "error"); return; }
332
334
 
333
- const thread = registry.create("chained", `Chain: ${phases.length} phases`, phases, { cwd: ctx.cwd, backend: "subagent" });
335
+ const thread = registry.create("chained", `Chain: ${phases.length} phases`, phases, { cwd: ctx.cwd, backend: "native" });
334
336
 
335
- ctx.ui.notify(`🧵 C-Thread ${thread.id}: ${phases.length} phases via subagent chain...`, "info");
336
- executor.dispatch(thread);
337
+ ctx.ui.notify(`🧵 C-Thread ${thread.id}: ${phases.length} phases with checkpoints...`, "info");
338
+ executor.dispatch(thread, {
339
+ onCheckpoint: async (phase, task) => ctx.ui.confirm(
340
+ "C-Thread checkpoint",
341
+ `Phase ${phase} complete. Continue to phase ${phase + 1}: ${task.label}?`
342
+ ),
343
+ });
337
344
  },
338
345
  });
339
346
 
@@ -472,10 +479,10 @@ export default function (pi: ExtensionAPI) {
472
479
  // ── /wthread — worktree isolation (port from Grok CLI) ────
473
480
 
474
481
  pi.registerCommand("wthread", {
475
- description: 'W-Thread: run task in isolated git worktree. Usage: /wthread "task" or /wthread list or /wthread cleanup',
482
+ description: 'W-Thread: run task in isolated git worktree. Usage: /wthread "task" or /wthread list, push, discard, cleanup',
476
483
  handler: async (args, ctx) => {
477
484
  if (!args?.trim()) {
478
- ctx.ui.notify('Usage: /wthread "task" | /wthread list | /wthread cleanup', "error");
485
+ ctx.ui.notify('Usage: /wthread "task" | /wthread list | /wthread push <id> [message] | /wthread discard <id> | /wthread cleanup', "error");
479
486
  return;
480
487
  }
481
488
 
@@ -501,7 +508,8 @@ export default function (pi: ExtensionAPI) {
501
508
  return;
502
509
  }
503
510
 
504
- if (sub === "push" && parts.length >= 2) {
511
+ if (sub === "push") {
512
+ if (parts.length < 2) { ctx.ui.notify('Usage: /wthread push <id> [message]', "error"); return; }
505
513
  const threadId = parts[1];
506
514
  const msg = parts.slice(2).join(" ") || undefined;
507
515
  const ok = pushWorktreeChanges(ctx.cwd, threadId, msg);
@@ -509,6 +517,14 @@ export default function (pi: ExtensionAPI) {
509
517
  return;
510
518
  }
511
519
 
520
+ if (sub === "discard" || sub === "remove") {
521
+ if (parts.length < 2) { ctx.ui.notify('Usage: /wthread discard <id>', "error"); return; }
522
+ const threadId = parts[1];
523
+ const ok = removeWorktree(ctx.cwd, threadId);
524
+ ctx.ui.notify(ok ? `Discarded ${threadId} worktree` : "Discard failed", ok ? "info" : "error");
525
+ return;
526
+ }
527
+
512
528
  // Default: create worktree and run task
513
529
  if (!isGitRepo(ctx.cwd)) { ctx.ui.notify("Not in a git repo — can't create worktree", "error"); return; }
514
530
 
@@ -778,6 +794,16 @@ export default function (pi: ExtensionAPI) {
778
794
  },
779
795
  });
780
796
 
797
+ // ── /hello — Greeting command ───────────────────────────────
798
+
799
+ pi.registerCommand("hello", {
800
+ description: 'Say hello. Usage: /hello [name]',
801
+ handler: async (args, ctx) => {
802
+ const name = args?.trim() || undefined;
803
+ ctx.ui.notify(helloWorld(name), "info");
804
+ },
805
+ });
806
+
781
807
  // ── LLM-callable tools ───────────────────────────────────────
782
808
 
783
809
  pi.registerTool({
@@ -785,18 +811,19 @@ export default function (pi: ExtensionAPI) {
785
811
  label: "Thread Spawn",
786
812
  description: [
787
813
  "Spawn a thread. Types:",
814
+ "- base: one prompt -> tool calls -> review (native)",
788
815
  "- parallel: N independent tasks in parallel (via subagent)",
789
- "- chained: sequential phases with checkpoints (via subagent)",
790
- "- meta: scoutplanbuildreview pipeline (via subagent)",
816
+ "- chained: sequential phases with human checkpoints (native)",
817
+ "- meta: scout -> plan -> build -> review pipeline (via subagent)",
791
818
  "- fusion: same prompt to N agents/models, compare results (native, UNIQUE)",
792
819
  "- zero: autonomous + verification command gate (native, UNIQUE)",
793
820
  "- long: extended autonomous run (native)",
794
- "- worktree: run in isolated git worktree (native, port from Grok CLI)",
795
- "- plan: structured planapproveexecute gate (native, port from Grok CLI)",
796
- "- scheduled: recurring scheduled task (native, port from Grok CLI)",
821
+ "- worktree: run in isolated git worktree (native, extension beyond article)",
822
+ "- plan: structured plan -> approve -> execute gate (native, extension beyond article)",
823
+ "- scheduled: recurring scheduled task (native, extension beyond article)",
797
824
  ].join("\n"),
798
825
  parameters: Type.Object({
799
- type: StringEnum(["parallel", "fusion", "chained", "meta", "long", "zero", "worktree", "plan", "scheduled"] as const),
826
+ type: StringEnum(["base", "parallel", "fusion", "chained", "meta", "long", "zero", "worktree", "plan", "scheduled"] as const),
800
827
  prompts: Type.Array(Type.String(), { description: "Task prompts" }),
801
828
  models: Type.Optional(Type.Array(Type.String(), { description: "Models for fusion (e.g. ['anthropic/claude-sonnet-4', 'google/gemini-2.5-pro'])" })),
802
829
  count: Type.Optional(Type.Number({ description: "Agent count for fusion (default 3)" })),
@@ -817,8 +844,9 @@ export default function (pi: ExtensionAPI) {
817
844
  taskPrompts = [prompts[0]]; // Meta delegates to subagent chain internally
818
845
  }
819
846
 
820
- // Auto-select backend
821
- const backend = backendOverride ?? (type === "fusion" || type === "zero" || type === "long" ? "native" : "subagent");
847
+ // Auto-select backend: P/B use subagents; C/F/L/Z and extension types run natively.
848
+ const nativeTypes = new Set(["base", "chained", "fusion", "zero", "long", "worktree", "plan", "scheduled"]);
849
+ const backend: import("../src/core/types.js").ExecutionBackend = (backendOverride as import("../src/core/types.js").ExecutionBackend) ?? (nativeTypes.has(String(type)) ? "native" as const : "subagent" as const);
822
850
 
823
851
  const label = type === "fusion"
824
852
  ? `Fusion: ${prompts[0]?.slice(0, 40)}`
@@ -833,7 +861,12 @@ export default function (pi: ExtensionAPI) {
833
861
  });
834
862
 
835
863
  // Dispatch (async — runs in background)
836
- executor.dispatch(thread);
864
+ executor.dispatch(thread, type === "chained" ? {
865
+ onCheckpoint: async (phase, task) => ctx.ui.confirm(
866
+ "C-Thread checkpoint",
867
+ `Phase ${phase} complete. Continue to phase ${phase + 1}: ${task.label}?`
868
+ ),
869
+ } : undefined);
837
870
 
838
871
  const modelInfo = models ? ` Models: ${models.join(", ")}` : "";
839
872
  const verifyInfo = verify ? ` Verify: ${verify}` : "";
@@ -847,7 +880,7 @@ export default function (pi: ExtensionAPI) {
847
880
  details: { threadId: thread.id, type, taskCount: taskPrompts.length, backend },
848
881
  };
849
882
  },
850
- renderCall(args, theme) {
883
+ renderCall(args: any, theme: any) {
851
884
  return new Text(
852
885
  theme.fg("toolTitle", theme.bold("thread_spawn ")) +
853
886
  theme.fg("accent", args.type) +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-thread-engine",
3
- "version": "0.4.7",
3
+ "version": "0.4.9",
4
4
  "description": "Thread-Based Engineering for pi — all 7 thread types + stories + fusion + zero-touch + TUI dashboard. Based on @IndyDevDan framework from agenticengineer.com.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -21,22 +21,31 @@
21
21
  "pi": {
22
22
  "extensions": [
23
23
  "./extensions/index.ts"
24
- ]
24
+ ],
25
+ "commands": ["threads","agents","pthread","cthread","bthread","fthread","zthread","lthread","wthread","plan","tloop","story","stories","hello"],
26
+ "tools": ["thread_spawn","thread_status","thread_kill"]
25
27
  },
26
28
  "files": [
29
+ "_lib/",
27
30
  "extensions/",
28
31
  "src/",
29
32
  "README.md",
30
33
  "PLAN.md",
31
34
  "docs/"
32
35
  ],
36
+ "scripts": {
37
+ "build": "bash scripts/build.sh",
38
+ "test": "vitest run",
39
+ "test:watch": "vitest"
40
+ },
33
41
  "devDependencies": {
34
- "typescript": "^6.0.3"
42
+ "typescript": "^6.0.3",
43
+ "vitest": "^4.1.6"
35
44
  },
36
45
  "peerDependencies": {
37
- "@mariozechner/pi-coding-agent": ">=0.73.0",
38
- "@mariozechner/pi-tui": ">=0.73.0",
39
- "@mariozechner/pi-ai": ">=0.73.0",
46
+ "@earendil-works/pi-ai": ">=0.73.0",
47
+ "@earendil-works/pi-coding-agent": ">=0.73.0",
48
+ "@earendil-works/pi-tui": ">=0.73.0",
40
49
  "@sinclair/typebox": "^0.34.48"
41
50
  }
42
51
  }
@@ -17,15 +17,22 @@ export class ThreadExecutor {
17
17
 
18
18
  // ── Native execution (pi -p) ────────────────────────────────
19
19
 
20
+ private async execPi(args: string[], opts: { cwd: string; timeout: number }) {
21
+ if (process.platform === "win32") {
22
+ return this.pi.exec("cmd.exe", ["/d", "/s", "/c", "pi", ...args], opts);
23
+ }
24
+ return this.pi.exec("pi", args, opts);
25
+ }
26
+
20
27
  private async runTaskNative(thread: Thread, task: ThreadTask): Promise<void> {
21
28
  const cwd = thread.config.cwd ?? process.cwd();
22
29
  this.registry.startTask(thread.id, task.id);
23
30
 
24
31
  try {
25
- const args = ["-p", task.prompt];
32
+ const args = ["--no-session", "--no-extensions", "--no-skills", "--no-context-files", "-p", task.prompt];
26
33
  if (task.model) args.unshift("-m", task.model);
27
34
 
28
- const result = await this.pi.exec("pi", args, {
35
+ const result = await this.execPi(args, {
29
36
  cwd,
30
37
  timeout: 10 * 60 * 1000,
31
38
  });
@@ -226,7 +233,7 @@ export class ThreadExecutor {
226
233
  this.registry.startThread(thread.id);
227
234
 
228
235
  try {
229
- const { createWorktree, removeWorktree, findRepoRoot, pushWorktreeChanges } = await import("./worktree.js");
236
+ const { createWorktree, findRepoRoot } = await import("./worktree.js");
230
237
  const repoPath = findRepoRoot(cwd);
231
238
 
232
239
  if (!repoPath) {
@@ -245,7 +252,7 @@ export class ThreadExecutor {
245
252
 
246
253
  // Run the task inside the worktree using pi -p
247
254
  try {
248
- const result = await this.pi.exec("pi", ["-p", task.prompt], {
255
+ const result = await this.execPi(["--no-session", "--no-extensions", "--no-skills", "--no-context-files", "-p", task.prompt], {
249
256
  cwd: wt.path,
250
257
  timeout: 30 * 60 * 1000, // 30 min max
251
258
  });
@@ -267,8 +274,7 @@ export class ThreadExecutor {
267
274
  this.registry.failTask(thread.id, task.id, err.message ?? String(err));
268
275
  }
269
276
 
270
- // Always clean up worktree after execution
271
- removeWorktree(cwd, thread.id);
277
+ // Preserve the worktree/branch for explicit /wthread push, discard, or cleanup.
272
278
  } catch (err: any) {
273
279
  this.registry.failTask(thread.id, thread.tasks[0].id, err.message ?? String(err));
274
280
  }
@@ -0,0 +1,32 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { ThreadRegistry } from "./registry.js";
3
+
4
+ describe("ThreadRegistry kill semantics", () => {
5
+ it("does not resurrect killed threads when async work completes late", () => {
6
+ const registry = new ThreadRegistry();
7
+ const thread = registry.create("base", "late finish", ["prompt"], { backend: "native" });
8
+
9
+ registry.startThread(thread.id);
10
+ registry.startTask(thread.id, thread.tasks[0].id);
11
+ registry.kill(thread.id);
12
+ registry.completeTask(thread.id, thread.tasks[0].id, "late result");
13
+
14
+ expect(thread.state).toBe("killed");
15
+ expect(thread.tasks[0].state).toBe("killed");
16
+ expect(thread.tasks[0].result).toBeUndefined();
17
+ });
18
+
19
+ it("does not resurrect killed threads when async work fails late", () => {
20
+ const registry = new ThreadRegistry();
21
+ const thread = registry.create("base", "late failure", ["prompt"], { backend: "native" });
22
+
23
+ registry.startThread(thread.id);
24
+ registry.startTask(thread.id, thread.tasks[0].id);
25
+ registry.kill(thread.id);
26
+ registry.failTask(thread.id, thread.tasks[0].id, "late error");
27
+
28
+ expect(thread.state).toBe("killed");
29
+ expect(thread.tasks[0].state).toBe("killed");
30
+ expect(thread.tasks[0].error).toBeUndefined();
31
+ });
32
+ });