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.
@@ -1,290 +1,290 @@
1
- import type { ExecutionBackend, Story, StoryPhase, Thread, ThreadEvent, ThreadState, ThreadSummary, ThreadTask, ThreadType } from "./types.js";
2
-
3
- type EventHandler = (event: ThreadEvent) => void;
4
-
5
- let nextThreadId = 1;
6
- let nextStoryId = 1;
7
-
8
- function genThreadId(): string {
9
- return `t-${String(nextThreadId++).padStart(3, "0")}`;
10
- }
11
-
12
- function genStoryId(): string {
13
- return `s-${String(nextStoryId++).padStart(3, "0")}`;
14
- }
15
-
16
- function genTaskId(threadId: string, index: number): string {
17
- return `${threadId}.${index + 1}`;
18
- }
19
-
20
- /** Format elapsed time */
21
- export function formatElapsed(ms: number): string {
22
- if (ms < 1000) return `${ms}ms`;
23
- const s = Math.floor(ms / 1000);
24
- if (s < 60) return `${s}s`;
25
- const m = Math.floor(s / 60);
26
- const rem = s % 60;
27
- if (m < 60) return `${m}m ${rem}s`;
28
- const h = Math.floor(m / 60);
29
- return `${h}h ${m % 60}m`;
30
- }
31
-
32
- /** Central registry for all threads and stories */
33
- export class ThreadRegistry {
34
- private threads = new Map<string, Thread>();
35
- private stories = new Map<string, Story>();
36
- private handlers: EventHandler[] = [];
37
-
38
- on(handler: EventHandler): () => void {
39
- this.handlers.push(handler);
40
- return () => {
41
- const idx = this.handlers.indexOf(handler);
42
- if (idx >= 0) this.handlers.splice(idx, 1);
43
- };
44
- }
45
-
46
- private emit(event: ThreadEvent) {
47
- for (const h of this.handlers) {
48
- try {
49
- h(event);
50
- } catch (e) {
51
- console.error("[pi-threads] event handler error:", e);
52
- }
53
- }
54
- }
55
-
56
- // ── Threads ──────────────────────────────────────────────────
57
-
58
- create(
59
- type: ThreadType,
60
- label: string,
61
- prompts: string[],
62
- opts?: { models?: string[]; cwd?: string; backend?: ExecutionBackend; agent?: string; verify?: string }
63
- ): Thread {
64
- const threadId = genThreadId();
65
- const tasks: ThreadTask[] = prompts.map((prompt, i) => ({
66
- id: genTaskId(threadId, i),
67
- label: prompt.length > 60 ? prompt.slice(0, 57) + "..." : prompt,
68
- prompt,
69
- model: opts?.models?.[i],
70
- state: "pending" as ThreadState,
71
- }));
72
-
73
- const thread: Thread = {
74
- id: threadId,
75
- type,
76
- label,
77
- state: "pending",
78
- config: {
79
- type,
80
- tasks,
81
- backend: opts?.backend ?? "native",
82
- cwd: opts?.cwd,
83
- models: opts?.models,
84
- agent: opts?.agent,
85
- verifyCommand: opts?.verify,
86
- },
87
- tasks,
88
- createdAt: Date.now(),
89
- };
90
-
91
- this.threads.set(threadId, thread);
92
- this.emit({ type: "thread_created", thread });
93
- return thread;
94
- }
95
-
96
- get(id: string): Thread | undefined {
97
- return this.threads.get(id);
98
- }
99
-
100
- all(): Thread[] {
101
- return [...this.threads.values()];
102
- }
103
-
104
- byState(state: ThreadState): Thread[] {
105
- return this.all().filter((t) => t.state === state);
106
- }
107
-
108
- startThread(id: string) {
109
- const t = this.threads.get(id);
110
- if (!t) return;
111
- t.state = "running";
112
- t.startedAt = Date.now();
113
- this.emit({ type: "thread_started", thread: t });
114
- }
115
-
116
- startTask(threadId: string, taskId: string, sessionId?: string) {
117
- const t = this.threads.get(threadId);
118
- if (!t) return;
119
- const task = t.tasks.find((tk) => tk.id === taskId);
120
- if (!task) return;
121
- task.state = "running";
122
- task.startedAt = Date.now();
123
- if (sessionId) task.sessionId = sessionId;
124
- this.emit({ type: "task_started", thread: t, task });
125
- }
126
-
127
- completeTask(threadId: string, taskId: string, result?: string) {
128
- const t = this.threads.get(threadId);
129
- if (!t) return;
130
- const task = t.tasks.find((tk) => tk.id === taskId);
131
- if (!task) return;
132
- task.state = "completed";
133
- task.completedAt = Date.now();
134
- if (result) task.result = result;
135
- this.emit({ type: "task_completed", thread: t, task });
136
-
137
- if (t.tasks.every((tk) => tk.state === "completed")) {
138
- t.state = "completed";
139
- t.completedAt = Date.now();
140
- t.duration = t.completedAt - (t.startedAt ?? t.createdAt);
141
- this.emit({ type: "thread_completed", thread: t });
142
- }
143
- }
144
-
145
- failTask(threadId: string, taskId: string, error: string) {
146
- const t = this.threads.get(threadId);
147
- if (!t) return;
148
- const task = t.tasks.find((tk) => tk.id === taskId);
149
- if (!task) return;
150
- task.state = "failed";
151
- task.completedAt = Date.now();
152
- task.error = error;
153
- this.emit({ type: "task_failed", thread: t, task });
154
-
155
- // For parallel/fusion: don't fail the whole thread if one task fails
156
- if (t.type === "parallel" || t.type === "fusion") {
157
- const allDone = t.tasks.every((tk) => tk.state === "completed" || tk.state === "failed");
158
- if (allDone) {
159
- const anySuccess = t.tasks.some((tk) => tk.state === "completed");
160
- t.state = anySuccess ? "completed" : "failed";
161
- t.completedAt = Date.now();
162
- t.duration = t.completedAt - (t.startedAt ?? t.createdAt);
163
- this.emit(anySuccess ? { type: "thread_completed", thread: t } : { type: "thread_failed", thread: t });
164
- }
165
- } else {
166
- t.state = "failed";
167
- t.completedAt = Date.now();
168
- t.duration = t.completedAt - (t.startedAt ?? t.createdAt);
169
- this.emit({ type: "thread_failed", thread: t });
170
- }
171
- }
172
-
173
- kill(id: string) {
174
- const t = this.threads.get(id);
175
- if (!t) return;
176
- t.state = "killed";
177
- t.completedAt = Date.now();
178
- t.duration = t.completedAt - (t.startedAt ?? t.createdAt);
179
- for (const task of t.tasks) {
180
- if (task.state === "running" || task.state === "pending") {
181
- task.state = "killed";
182
- task.completedAt = Date.now();
183
- }
184
- }
185
- this.emit({ type: "thread_killed", thread: t });
186
- }
187
-
188
- summarize(thread: Thread): ThreadSummary {
189
- const done = thread.tasks.filter((t) => t.state === "completed").length;
190
- const failed = thread.tasks.filter((t) => t.state === "failed").length;
191
- const total = thread.tasks.length;
192
- const elapsed = thread.startedAt ? formatElapsed(Date.now() - thread.startedAt) : "-";
193
- const progress = failed > 0 ? `${done}/${total} (${failed}✗)` : `${done}/${total}`;
194
-
195
- return {
196
- id: thread.id,
197
- type: thread.type,
198
- label: thread.label,
199
- state: thread.state,
200
- progress,
201
- elapsed,
202
- totalTokens: thread.tasks.reduce((a, tk) => a + (tk.usage?.totalTokens ?? 0), 0),
203
- totalCost: thread.tasks.reduce((a, tk) => a + (tk.usage?.cost ?? 0), 0),
204
- backend: thread.config.backend,
205
- };
206
- }
207
-
208
- prune() {
209
- for (const [id, t] of this.threads) {
210
- if (t.state === "completed" || t.state === "failed" || t.state === "killed") {
211
- this.threads.delete(id);
212
- }
213
- }
214
- }
215
-
216
- /** Restore state from session persistence */
217
- restore(threads: Thread[], stories: Story[]) {
218
- for (const t of threads) {
219
- this.threads.set(t.id, t);
220
- // Advance ID counter past restored IDs to avoid collisions
221
- const num = parseInt(t.id.replace("t-", ""), 10);
222
- if (!isNaN(num) && num >= nextThreadId) nextThreadId = num + 1;
223
- }
224
- for (const s of stories) {
225
- this.stories.set(s.id, s);
226
- const num = parseInt(s.id.replace("s-", ""), 10);
227
- if (!isNaN(num) && num >= nextStoryId) nextStoryId = num + 1;
228
- }
229
- }
230
-
231
- // ── Stories ──────────────────────────────────────────────────
232
-
233
- createStory(goal: string, verify?: string): Story {
234
- const story: Story = {
235
- id: genStoryId(),
236
- goal,
237
- state: "planning",
238
- phases: [],
239
- createdAt: Date.now(),
240
- verify,
241
- artifacts: [],
242
- };
243
- this.stories.set(story.id, story);
244
- this.emit({ type: "story_created", story });
245
- return story;
246
- }
247
-
248
- getStory(id: string): Story | undefined {
249
- return this.stories.get(id);
250
- }
251
-
252
- allStories(): Story[] {
253
- return [...this.stories.values()];
254
- }
255
-
256
- addPhase(storyId: string, phase: StoryPhase) {
257
- const s = this.stories.get(storyId);
258
- if (!s) return;
259
- s.phases.push(phase);
260
- }
261
-
262
- startPhase(storyId: string, phaseIndex: number, threadId: string) {
263
- const s = this.stories.get(storyId);
264
- if (!s || !s.phases[phaseIndex]) return;
265
- s.phases[phaseIndex].state = "running";
266
- s.phases[phaseIndex].threadId = threadId;
267
- s.state = "executing";
268
- this.emit({ type: "story_phase_started", story: s, phase: s.phases[phaseIndex] });
269
- }
270
-
271
- completePhase(storyId: string, phaseIndex: number) {
272
- const s = this.stories.get(storyId);
273
- if (!s || !s.phases[phaseIndex]) return;
274
- s.phases[phaseIndex].state = "completed";
275
-
276
- if (s.phases.every((p) => p.state === "completed")) {
277
- s.state = "done";
278
- s.completedAt = Date.now();
279
- this.emit({ type: "story_completed", story: s });
280
- }
281
- }
282
-
283
- failStory(storyId: string) {
284
- const s = this.stories.get(storyId);
285
- if (!s) return;
286
- s.state = "failed";
287
- s.completedAt = Date.now();
288
- this.emit({ type: "story_failed", story: s });
289
- }
290
- }
1
+ import type { ExecutionBackend, Story, StoryPhase, Thread, ThreadEvent, ThreadState, ThreadSummary, ThreadTask, ThreadType } from "./types.js";
2
+
3
+ type EventHandler = (event: ThreadEvent) => void;
4
+
5
+ let nextThreadId = 1;
6
+ let nextStoryId = 1;
7
+
8
+ function genThreadId(): string {
9
+ return `t-${String(nextThreadId++).padStart(3, "0")}`;
10
+ }
11
+
12
+ function genStoryId(): string {
13
+ return `s-${String(nextStoryId++).padStart(3, "0")}`;
14
+ }
15
+
16
+ function genTaskId(threadId: string, index: number): string {
17
+ return `${threadId}.${index + 1}`;
18
+ }
19
+
20
+ /** Format elapsed time */
21
+ export function formatElapsed(ms: number): string {
22
+ if (ms < 1000) return `${ms}ms`;
23
+ const s = Math.floor(ms / 1000);
24
+ if (s < 60) return `${s}s`;
25
+ const m = Math.floor(s / 60);
26
+ const rem = s % 60;
27
+ if (m < 60) return `${m}m ${rem}s`;
28
+ const h = Math.floor(m / 60);
29
+ return `${h}h ${m % 60}m`;
30
+ }
31
+
32
+ /** Central registry for all threads and stories */
33
+ export class ThreadRegistry {
34
+ private threads = new Map<string, Thread>();
35
+ private stories = new Map<string, Story>();
36
+ private handlers: EventHandler[] = [];
37
+
38
+ on(handler: EventHandler): () => void {
39
+ this.handlers.push(handler);
40
+ return () => {
41
+ const idx = this.handlers.indexOf(handler);
42
+ if (idx >= 0) this.handlers.splice(idx, 1);
43
+ };
44
+ }
45
+
46
+ private emit(event: ThreadEvent) {
47
+ for (const h of this.handlers) {
48
+ try {
49
+ h(event);
50
+ } catch (e) {
51
+ console.error("[pi-threads] event handler error:", e);
52
+ }
53
+ }
54
+ }
55
+
56
+ // ── Threads ──────────────────────────────────────────────────
57
+
58
+ create(
59
+ type: ThreadType,
60
+ label: string,
61
+ prompts: string[],
62
+ opts?: { models?: string[]; cwd?: string; backend?: ExecutionBackend; agent?: string; verify?: string }
63
+ ): Thread {
64
+ const threadId = genThreadId();
65
+ const tasks: ThreadTask[] = prompts.map((prompt, i) => ({
66
+ id: genTaskId(threadId, i),
67
+ label: prompt.length > 60 ? prompt.slice(0, 57) + "..." : prompt,
68
+ prompt,
69
+ model: opts?.models?.[i],
70
+ state: "pending" as ThreadState,
71
+ }));
72
+
73
+ const thread: Thread = {
74
+ id: threadId,
75
+ type,
76
+ label,
77
+ state: "pending",
78
+ config: {
79
+ type,
80
+ tasks,
81
+ backend: opts?.backend ?? "native",
82
+ cwd: opts?.cwd,
83
+ models: opts?.models,
84
+ agent: opts?.agent,
85
+ verifyCommand: opts?.verify,
86
+ },
87
+ tasks,
88
+ createdAt: Date.now(),
89
+ };
90
+
91
+ this.threads.set(threadId, thread);
92
+ this.emit({ type: "thread_created", thread });
93
+ return thread;
94
+ }
95
+
96
+ get(id: string): Thread | undefined {
97
+ return this.threads.get(id);
98
+ }
99
+
100
+ all(): Thread[] {
101
+ return [...this.threads.values()];
102
+ }
103
+
104
+ byState(state: ThreadState): Thread[] {
105
+ return this.all().filter((t) => t.state === state);
106
+ }
107
+
108
+ startThread(id: string) {
109
+ const t = this.threads.get(id);
110
+ if (!t) return;
111
+ t.state = "running";
112
+ t.startedAt = Date.now();
113
+ this.emit({ type: "thread_started", thread: t });
114
+ }
115
+
116
+ startTask(threadId: string, taskId: string, sessionId?: string) {
117
+ const t = this.threads.get(threadId);
118
+ if (!t) return;
119
+ const task = t.tasks.find((tk) => tk.id === taskId);
120
+ if (!task) return;
121
+ task.state = "running";
122
+ task.startedAt = Date.now();
123
+ if (sessionId) task.sessionId = sessionId;
124
+ this.emit({ type: "task_started", thread: t, task });
125
+ }
126
+
127
+ completeTask(threadId: string, taskId: string, result?: string) {
128
+ const t = this.threads.get(threadId);
129
+ if (!t || t.state === "killed") return;
130
+ const task = t.tasks.find((tk) => tk.id === taskId);
131
+ if (!task) return;
132
+ task.state = "completed";
133
+ task.completedAt = Date.now();
134
+ if (result) task.result = result;
135
+ this.emit({ type: "task_completed", thread: t, task });
136
+
137
+ if (t.tasks.every((tk) => tk.state === "completed")) {
138
+ t.state = "completed";
139
+ t.completedAt = Date.now();
140
+ t.duration = t.completedAt - (t.startedAt ?? t.createdAt);
141
+ this.emit({ type: "thread_completed", thread: t });
142
+ }
143
+ }
144
+
145
+ failTask(threadId: string, taskId: string, error: string) {
146
+ const t = this.threads.get(threadId);
147
+ if (!t || t.state === "killed") return;
148
+ const task = t.tasks.find((tk) => tk.id === taskId);
149
+ if (!task) return;
150
+ task.state = "failed";
151
+ task.completedAt = Date.now();
152
+ task.error = error;
153
+ this.emit({ type: "task_failed", thread: t, task });
154
+
155
+ // For parallel/fusion: don't fail the whole thread if one task fails
156
+ if (t.type === "parallel" || t.type === "fusion") {
157
+ const allDone = t.tasks.every((tk) => tk.state === "completed" || tk.state === "failed");
158
+ if (allDone) {
159
+ const anySuccess = t.tasks.some((tk) => tk.state === "completed");
160
+ t.state = anySuccess ? "completed" : "failed";
161
+ t.completedAt = Date.now();
162
+ t.duration = t.completedAt - (t.startedAt ?? t.createdAt);
163
+ this.emit(anySuccess ? { type: "thread_completed", thread: t } : { type: "thread_failed", thread: t });
164
+ }
165
+ } else {
166
+ t.state = "failed";
167
+ t.completedAt = Date.now();
168
+ t.duration = t.completedAt - (t.startedAt ?? t.createdAt);
169
+ this.emit({ type: "thread_failed", thread: t });
170
+ }
171
+ }
172
+
173
+ kill(id: string) {
174
+ const t = this.threads.get(id);
175
+ if (!t) return;
176
+ t.state = "killed";
177
+ t.completedAt = Date.now();
178
+ t.duration = t.completedAt - (t.startedAt ?? t.createdAt);
179
+ for (const task of t.tasks) {
180
+ if (task.state === "running" || task.state === "pending") {
181
+ task.state = "killed";
182
+ task.completedAt = Date.now();
183
+ }
184
+ }
185
+ this.emit({ type: "thread_killed", thread: t });
186
+ }
187
+
188
+ summarize(thread: Thread): ThreadSummary {
189
+ const done = thread.tasks.filter((t) => t.state === "completed").length;
190
+ const failed = thread.tasks.filter((t) => t.state === "failed").length;
191
+ const total = thread.tasks.length;
192
+ const elapsed = thread.startedAt ? formatElapsed(Date.now() - thread.startedAt) : "-";
193
+ const progress = failed > 0 ? `${done}/${total} (${failed}✗)` : `${done}/${total}`;
194
+
195
+ return {
196
+ id: thread.id,
197
+ type: thread.type,
198
+ label: thread.label,
199
+ state: thread.state,
200
+ progress,
201
+ elapsed,
202
+ totalTokens: thread.tasks.reduce((a, tk) => a + (tk.usage?.totalTokens ?? 0), 0),
203
+ totalCost: thread.tasks.reduce((a, tk) => a + (tk.usage?.cost ?? 0), 0),
204
+ backend: thread.config.backend,
205
+ };
206
+ }
207
+
208
+ prune() {
209
+ for (const [id, t] of this.threads) {
210
+ if (t.state === "completed" || t.state === "failed" || t.state === "killed") {
211
+ this.threads.delete(id);
212
+ }
213
+ }
214
+ }
215
+
216
+ /** Restore state from session persistence */
217
+ restore(threads: Thread[], stories: Story[]) {
218
+ for (const t of threads) {
219
+ this.threads.set(t.id, t);
220
+ // Advance ID counter past restored IDs to avoid collisions
221
+ const num = parseInt(t.id.replace("t-", ""), 10);
222
+ if (!isNaN(num) && num >= nextThreadId) nextThreadId = num + 1;
223
+ }
224
+ for (const s of stories) {
225
+ this.stories.set(s.id, s);
226
+ const num = parseInt(s.id.replace("s-", ""), 10);
227
+ if (!isNaN(num) && num >= nextStoryId) nextStoryId = num + 1;
228
+ }
229
+ }
230
+
231
+ // ── Stories ──────────────────────────────────────────────────
232
+
233
+ createStory(goal: string, verify?: string): Story {
234
+ const story: Story = {
235
+ id: genStoryId(),
236
+ goal,
237
+ state: "planning",
238
+ phases: [],
239
+ createdAt: Date.now(),
240
+ verify,
241
+ artifacts: [],
242
+ };
243
+ this.stories.set(story.id, story);
244
+ this.emit({ type: "story_created", story });
245
+ return story;
246
+ }
247
+
248
+ getStory(id: string): Story | undefined {
249
+ return this.stories.get(id);
250
+ }
251
+
252
+ allStories(): Story[] {
253
+ return [...this.stories.values()];
254
+ }
255
+
256
+ addPhase(storyId: string, phase: StoryPhase) {
257
+ const s = this.stories.get(storyId);
258
+ if (!s) return;
259
+ s.phases.push(phase);
260
+ }
261
+
262
+ startPhase(storyId: string, phaseIndex: number, threadId: string) {
263
+ const s = this.stories.get(storyId);
264
+ if (!s || !s.phases[phaseIndex]) return;
265
+ s.phases[phaseIndex].state = "running";
266
+ s.phases[phaseIndex].threadId = threadId;
267
+ s.state = "executing";
268
+ this.emit({ type: "story_phase_started", story: s, phase: s.phases[phaseIndex] });
269
+ }
270
+
271
+ completePhase(storyId: string, phaseIndex: number) {
272
+ const s = this.stories.get(storyId);
273
+ if (!s || !s.phases[phaseIndex]) return;
274
+ s.phases[phaseIndex].state = "completed";
275
+
276
+ if (s.phases.every((p) => p.state === "completed")) {
277
+ s.state = "done";
278
+ s.completedAt = Date.now();
279
+ this.emit({ type: "story_completed", story: s });
280
+ }
281
+ }
282
+
283
+ failStory(storyId: string) {
284
+ const s = this.stories.get(storyId);
285
+ if (!s) return;
286
+ s.state = "failed";
287
+ s.completedAt = Date.now();
288
+ this.emit({ type: "story_failed", story: s });
289
+ }
290
+ }