pi-orchestrator-config 4.3.14 → 4.4.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.
package/README.md CHANGED
@@ -263,14 +263,16 @@ model contract. It does not filter discovery results or claim that the endpoint
263
263
 
264
264
  `review_loop_enforcement` controls automatic review-agent dispatch and commit enforcement.
265
265
 
266
- - `true` automatically dispatches 6 agents in parallel (5 reviewers + `test-automator`) after code changes and requires a clean review before commits.
266
+ - `true` automatically dispatches 6 agents in parallel (5 reviewers + `test-runner`) after code changes and requires a clean review before commits.
267
267
  - `false` disables automatic review-agent and test-agent dispatch. Manual reviews and test runs are optional. No review state, cycle, or result is required before commit.
268
268
 
269
269
  When `review_loop_enforcement` is enabled, the loop stops once all reviewers approve with 0 findings and tests pass
270
- (`tests_passed: true` in `pi-config-review-state.jsonl`), OR after `review_loop_max_cycles` total cycles (default `3`,
270
+ (`tests_passed: true` in `pi-config-review-state.jsonl`). `tests_passed` is set only by a detected
271
+ successful test command; completion of async `test-runner` or `test-automator` agents does not set it.
272
+ OR after `review_loop_max_cycles` total cycles (default `3`,
271
273
  valid integers `1`-`10`; env: digit string `"1"`-`"10"` only (after trim)) — whichever comes first. Each cycle
272
274
  always completes fix/explain (5a) before the cap check; the cap only blocks re-dispatch
273
- (step 2 / all 6 agents, including test-automator), not responding to findings.
275
+ (step 2 / all 6 agents, including test-runner), not responding to findings.
274
276
  At cap, report **Not fixed** (explained why not → outstanding) vs **Fixed**
275
277
  (verification blocked by the cap — cannot re-dispatch to confirm clean). Invalid values (including non-digit
276
278
  forms like `"10.0"` / `"1e1"`) fall through to the next resolution layer / default `3`. Disable the review loop via
@@ -32,7 +32,7 @@ You are a specialized test execution agent. Run tests and provide concise failur
32
32
  Failed Test 1: test_name (file:line)
33
33
  Expected: [brief description]
34
34
  Actual: [brief description]
35
- Fix location: path/to/file.rb:line
35
+ Fix location: path/to/file:line
36
36
  Suggested approach: [one line]
37
37
 
38
38
  Returning control for fixes.
@@ -16,7 +16,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
16
16
  import type { AgentConfig, AgentDiscoveryResult } from "./agents.js";
17
17
  import { resolveAgentModelProvider } from "./resolve-agent-model.js";
18
18
  import { getPiInvocation, getProjectTmpDir, parseProcStartTime, djb2Hash } from "./utils.js";
19
- import { addReviewerPending, recordReviewerResult, countFindings, readReviewState, markTestsPassed, markTestsFailed } from "./pi-config-review-state.js";
19
+ import { addReviewerPending, recordReviewerResult, countFindings, readReviewState } from "./pi-config-review-state.js";
20
20
  import {
21
21
  getMainBranch,
22
22
  } from "./git-helpers.js";
@@ -337,9 +337,6 @@ export function registerAsyncAgents(
337
337
  const findings = countFindings(typeof data.output === "string" ? data.output : "");
338
338
  try { recordReviewerResult(jobCwd(job), job.agent, findings < 0 ? 1 : findings); } catch (e: any) { zombieSideEffectsOk = false; log.error(`recordReviewerResult failed for ${job.agent}: ${e?.message}`); }
339
339
  }
340
- if (job.agent === "test-automator" || job.agent === "test-runner") {
341
- try { job.status === "complete" ? markTestsPassed(jobCwd(job)) : markTestsFailed(jobCwd(job)); } catch (e: any) { zombieSideEffectsOk = false; log.error(`markTests failed for ${job.agent}: ${e?.message}`); }
342
- }
343
340
  if (zombieSideEffectsOk) job.sideEffectsApplied = true;
344
341
  try { fs.unlinkSync(resultFilePath); } catch {}
345
342
  } catch {
@@ -405,9 +402,6 @@ export function registerAsyncAgents(
405
402
  const findings = countFindings(typeof job.output === "string" ? job.output : "");
406
403
  try { recordReviewerResult(jobCwd(job), job.agent, findings < 0 ? 1 : findings); } catch (e: any) { sideEffectsOk = false; log.error(`reconcile: recordReviewerResult failed for ${job.agent}: ${e?.message}`); }
407
404
  }
408
- if (job.agent === "test-automator" || job.agent === "test-runner") {
409
- try { job.status === "complete" ? markTestsPassed(jobCwd(job)) : markTestsFailed(jobCwd(job)); } catch (e: any) { sideEffectsOk = false; log.error(`reconcile: markTests failed for ${job.agent}: ${e?.message}`); }
410
- }
411
405
  if (sideEffectsOk) job.sideEffectsApplied = true;
412
406
  }
413
407
  // Delivery: retry for groups where all members are done
@@ -548,18 +542,6 @@ export function registerAsyncAgents(
548
542
  } catch (e: any) { j.sideEffectsApplied = false; log.error(`recordReviewerResult failed for ${j.agent}: ${e?.message}`); continue; }
549
543
  }
550
544
 
551
- // Track test agent completions for grouped test-automator/test-runner
552
- for (const j of groupJobs) {
553
- if (j.agent !== "test-automator" && j.agent !== "test-runner") continue;
554
- try {
555
- if (j.status === "complete") {
556
- markTestsPassed(jobCwd(j));
557
- } else if (j.status === "failed") {
558
- markTestsFailed(jobCwd(j));
559
- }
560
- } catch (e: any) { j.sideEffectsApplied = false; log.error(`markTests failed for ${j.agent}: ${e?.message}`); continue; }
561
- }
562
-
563
545
  // Mark side-effects applied per-job (only if not already marked false by a failed side-effect above)
564
546
  for (const j of groupJobs) {
565
547
  if (j.sideEffectsApplied !== false) j.sideEffectsApplied = true;
@@ -738,17 +720,6 @@ export function registerAsyncAgents(
738
720
  }
739
721
  }
740
722
 
741
- // Track test agent completion for review state test tracking
742
- if (job.agent === "test-automator" || job.agent === "test-runner") {
743
- try {
744
- if (data.success) {
745
- markTestsPassed(jobCwd(job));
746
- } else {
747
- markTestsFailed(jobCwd(job));
748
- }
749
- } catch (e: any) { processResultSideEffectsOk = false; log.error(`markTests(Passed|Failed) failed for ${job.agent}: ${e?.message}`); }
750
- }
751
-
752
723
  if (processResultSideEffectsOk) job.sideEffectsApplied = true;
753
724
 
754
725
  // Always delete result file after ingestion — data is in memory (job.output).
@@ -812,7 +812,7 @@ function bodyAlreadySigned(text: string): boolean {
812
812
 
813
813
  /** Unresolved model placeholders are stale template footers, not signatures. */
814
814
  function replaceUnresolvedModelFooters(payload: string): string {
815
- const unresolvedFooter = /(?:\n|\\n){2}---(?:\n|\\n)\*Assisted-by: PI \((?:\$PI_MODEL|\$\{PI_MODEL\})\)\*/g;
815
+ const unresolvedFooter = /(?:\n|\\n){2}---(?:\n|\\n)\*Assisted-by: PI \((?:\$PI_MODEL|\$\{PI_MODEL(?::-unknown)?\})\)\*/g;
816
816
  const replaced = payload.replace(unresolvedFooter, "");
817
817
  if (replaced !== payload)
818
818
  enfLog.debug("injectGhBodySignature replaced unresolved PI_MODEL footer");
@@ -939,7 +939,9 @@ export function injectGhBodySignature(command: string, signature: string): strin
939
939
 
940
940
  /**
941
941
  * Detect common test runner commands — require command-start position
942
- * (after &&, |, ;, or line start) to avoid false positives from install/grep/cat commands.
942
+ * (after &&, |, ;, a newline, or line start) to avoid false positives from install/grep/cat commands.
943
+ * `pre-commit` counts only when its `run` subcommand is executed; setup and information
944
+ * commands must not mark tests as passed.
943
945
  * NOTE: For compound commands (e.g., pytest && other_cmd), if the non-test part fails,
944
946
  * isError=true marks tests as failed even though pytest passed. This is the conservative/safe
945
947
  * direction — re-run the test command standalone to mark tests_passed.
@@ -947,11 +949,14 @@ export function injectGhBodySignature(command: string, signature: string): strin
947
949
  * match — we exclude tox with explicit -e to avoid marking lint/docs runs as test passes.
948
950
  */
949
951
  export function isTestRunnerCommand(command: string): boolean {
950
- return /(?:^|[;&|]\s*)(?:uv\s+run\s+(?:--\S+(?:\s+\S+)?\s+)*)?(?:pytest|vitest|jest|mocha)\b/.test(command)
952
+ const result = /(?:^|[;&|]\s*)(?:uv\s+run\s+(?:--\S+(?:\s+\S+)?\s+)*)?(?:pytest|vitest|jest|mocha)\b/.test(command)
951
953
  || /(?:^|[;&|]\s*)(?:uv\s+run\s+(?:--\S+(?:\s+\S+)?\s+)*)?tox\b(?!\s*-e)(?!\s+--(?:help|version|list))/.test(command)
952
954
  || /(?:^|[;&|]\s*)go\s+test\b/.test(command)
953
955
  || /(?:^|[;&|]\s*)npm\s+test\b/.test(command)
954
- || /(?:^|[;&|]\s*)npx\s+tsx\s+--test\b/.test(command);
956
+ || /(?:^|[;&|]\s*)npx\s+tsx\s+--test\b/.test(command)
957
+ || /(?:^|[;&|\n]\s*)pre-commit\s+run\b/.test(command);
958
+ enfLog.debug("isTestRunnerCommand", result);
959
+ return result;
955
960
  }
956
961
 
957
962
  /** True for release bump branches: chore/bump-version-<digit>... */
@@ -289,7 +289,7 @@ export function markTestsPassed(cwd: string): void {
289
289
  }
290
290
 
291
291
  /** Mark that tests have failed. No-op when review status is not being tracked.
292
- * Called when a detected test command exits non-zero, or test-automator/test-runner agent fails.
292
+ * Called when a detected test command exits non-zero.
293
293
  * Resets to false on any file edit via markNeedsReview(). */
294
294
  export function markTestsFailed(cwd: string): void {
295
295
  withStateLock(cwd, (state) => {
@@ -50,7 +50,11 @@ export async function autoCompleteTask(taskId: string, cwd: string, sessionId?:
50
50
  try {
51
51
  const task = store.get(taskId);
52
52
  if (!task) return false;
53
- if (task.status !== "completed") { store.update(taskId, { status: "completed" }); return true; }
53
+ if (task.status !== "completed") {
54
+ store.update(taskId, { status: "completed" });
55
+ (await import("../pitasks/index.js")).setTaskTelemetryActive(taskId, false);
56
+ return true;
57
+ }
54
58
  return false;
55
59
  } catch (e: any) {
56
60
  log.debug("autoCompleteTask failed for task", taskId, e?.message?.slice(0, 100));
@@ -89,7 +93,11 @@ export async function autoMarkInProgress(taskId: string, cwd: string, sessionId?
89
93
  try {
90
94
  const task = store.get(taskId);
91
95
  if (!task) return false;
92
- if (task.status === "pending") { store.update(taskId, { status: "in_progress" }); return true; }
96
+ if (task.status === "pending") {
97
+ store.update(taskId, { status: "in_progress" });
98
+ (await import("../pitasks/index.js")).setTaskTelemetryActive(taskId);
99
+ return true;
100
+ }
93
101
  return false;
94
102
  } catch (e: any) {
95
103
  log.debug("autoMarkInProgress failed for task", taskId, e?.message?.slice(0, 100));
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pidiff-ui",
3
3
  "private": true,
4
- "version": "4.3.14",
4
+ "version": "4.4.0",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "dev": "vite",
@@ -32,7 +32,12 @@ const log = createLogger("pitasks");
32
32
 
33
33
  // Module-level refs — survive closure replacement on /reload
34
34
  let currentUiCtx: any = null;
35
+ let currentStore: TaskStore | null = null;
35
36
  let currentWidget: TaskWidget | null = null;
37
+ export function setTaskTelemetryActive(taskId: string, active = true): void {
38
+ log.debug("task_telemetry_active", { taskId, active });
39
+ currentWidget?.setActiveTask(taskId, active);
40
+ }
36
41
 
37
42
  /** Exported store instance — set during extension init. */
38
43
  export let taskStore: TaskStore;
@@ -54,7 +59,10 @@ export function listTasks(): any[] {
54
59
 
55
60
  /** Update a task — for use by other extensions. */
56
61
  export function updateTask(id: string, fields: any): any {
57
- return taskStore.update(id, fields);
62
+ log.debug("task_updated", { taskId: id, fieldNames: Object.keys(fields) });
63
+ const result = taskStore.update(id, fields);
64
+ if (result.changedFields.includes("status")) setTaskTelemetryActive(id, fields.status === "in_progress");
65
+ return result;
58
66
  }
59
67
 
60
68
  /** Delete a task — for use by other extensions. */
@@ -66,58 +74,93 @@ export function deleteTask(id: string): boolean {
66
74
 
67
75
  /** Create a task on a specific session's store. Used by coms for sender-side task creation. */
68
76
  export function createTaskForSession(sessionId: string, subject: string, description: string, createdBy?: any, metadata?: Record<string, any>, targetCwd?: string, activeForm?: string): any {
77
+ log.debug("session_task_created", { sessionId });
69
78
  const base = targetCwd || process.cwd();
70
79
  const storePath = join(base, ".pi", "tasks", `tasks-${sessionId.replace(/[/\\]/g, "_")}.json`);
71
80
  const store = new TaskStore(storePath);
72
- return store.create(subject, description, createdBy || { type: "local", origin: "system", session: "", project: "" }, activeForm, metadata);
81
+ try {
82
+ return store.create(subject, description, createdBy || { type: "local", origin: "system", session: "", project: "" }, activeForm, metadata);
83
+ } finally {
84
+ store.close();
85
+ }
73
86
  }
74
87
 
75
88
  /** Create multiple tasks on a specific session's store. Used by coms for bulk task creation. */
76
89
  export function createTasksForSession(sessionId: string, tasks: Array<{ subject: string; description: string; createdBy: any; blockedBy?: string[]; activeForm?: string; metadata?: Record<string, any> }>, targetCwd?: string): any[] {
90
+ log.debug("session_tasks_created", { sessionId, count: tasks.length });
77
91
  const base = targetCwd || process.cwd();
78
92
  const storePath = join(base, ".pi", "tasks", `tasks-${sessionId.replace(/[/\\]/g, "_")}.json`);
79
93
  const store = new TaskStore(storePath);
80
- return store.createTasks(tasks);
94
+ try {
95
+ return store.createTasks(tasks);
96
+ } finally {
97
+ store.close();
98
+ }
81
99
  }
82
100
 
83
101
  /** Get a task from a specific session's store. */
84
102
  export function getTaskForSession(sessionId: string, taskId: string, targetCwd?: string): any {
103
+ log.debug("session_task_read", { sessionId, taskId });
85
104
  const base = targetCwd || process.cwd();
86
105
  const storePath = join(base, ".pi", "tasks", `tasks-${sessionId.replace(/[/\\]/g, "_")}.json`);
87
106
  const store = new TaskStore(storePath);
88
- return store.get(taskId);
107
+ try {
108
+ return store.get(taskId);
109
+ } finally {
110
+ store.close();
111
+ }
89
112
  }
90
113
 
91
114
  /** List tasks from a specific session's store. */
92
115
  export function listTasksForSession(sessionId: string, targetCwd?: string): any[] {
116
+ log.debug("session_tasks_listed", { sessionId });
93
117
  const base = targetCwd || process.cwd();
94
118
  const storePath = join(base, ".pi", "tasks", `tasks-${sessionId.replace(/[/\\]/g, "_")}.json`);
95
119
  const store = new TaskStore(storePath);
96
- return store.list();
120
+ try {
121
+ return store.list();
122
+ } finally {
123
+ store.close();
124
+ }
97
125
  }
98
126
 
99
127
  /** Update a task on a specific session's store. */
100
128
  export function updateTaskForSession(sessionId: string, taskId: string, fields: any, targetCwd?: string): any {
129
+ log.debug("session_task_updated", { sessionId, taskId, fieldNames: Object.keys(fields) });
101
130
  const base = targetCwd || process.cwd();
102
131
  const storePath = join(base, ".pi", "tasks", `tasks-${sessionId.replace(/[/\\]/g, "_")}.json`);
103
132
  const store = new TaskStore(storePath);
104
- return store.update(taskId, fields);
133
+ try {
134
+ return store.update(taskId, fields);
135
+ } finally {
136
+ store.close();
137
+ }
105
138
  }
106
139
 
107
140
  /** Update multiple tasks on a specific session's store. Used by coms for bulk updates. */
108
141
  export function updateTasksForSession(sessionId: string, updates: Array<{ id: string; fields: Record<string, any> }>, targetCwd?: string): any[] {
142
+ log.debug("session_tasks_updated", { sessionId, count: updates.length });
109
143
  const base = targetCwd || process.cwd();
110
144
  const storePath = join(base, ".pi", "tasks", `tasks-${sessionId.replace(/[/\\]/g, "_")}.json`);
111
145
  const store = new TaskStore(storePath);
112
- return store.updateTasks(updates);
146
+ try {
147
+ return store.updateTasks(updates);
148
+ } finally {
149
+ store.close();
150
+ }
113
151
  }
114
152
 
115
153
  /** Delete a task from a specific session's store. */
116
154
  export function deleteTaskForSession(sessionId: string, taskId: string, targetCwd?: string): boolean {
155
+ log.debug("session_task_deleted", { sessionId, taskId });
117
156
  const base = targetCwd || process.cwd();
118
157
  const storePath = join(base, ".pi", "tasks", `tasks-${sessionId.replace(/[/\\]/g, "_")}.json`);
119
158
  const store = new TaskStore(storePath);
120
- return store.delete(taskId);
159
+ try {
160
+ return store.delete(taskId);
161
+ } finally {
162
+ store.close();
163
+ }
121
164
  }
122
165
 
123
166
  export default function (pi: ExtensionAPI) {
@@ -139,9 +182,15 @@ export default function (pi: ExtensionAPI) {
139
182
  return join(process.cwd(), ".pi", "tasks", "tasks.json");
140
183
  }
141
184
 
185
+ const resumedActiveTaskIds = currentWidget?.getActiveTaskIds() ?? [];
186
+ currentStore?.setOnChange(() => {});
187
+ currentStore?.close();
188
+ currentWidget?.dispose();
142
189
  let store = new TaskStore(resolveStorePath());
143
190
  taskStore = store;
191
+ currentStore = store;
144
192
  const widget = new TaskWidget(store);
193
+ for (const taskId of resumedActiveTaskIds) widget.setActiveTask(taskId);
145
194
  currentWidget = widget;
146
195
  const instanceId = Math.random().toString(36).slice(2, 8);
147
196
  (globalThis as any).__pitasks_active_instance = instanceId;
@@ -154,8 +203,11 @@ export default function (pi: ExtensionAPI) {
154
203
  if (taskScope === "session" && !piTasks) {
155
204
  const sessionId = ctx.sessionManager?.getSessionId?.();
156
205
  const path = resolveStorePath(sessionId);
206
+ store.setOnChange(() => {});
207
+ store.close();
157
208
  store = new TaskStore(path);
158
209
  taskStore = store;
210
+ currentStore = store;
159
211
  widget.setStore(store);
160
212
  store.setOnChange(() => { widget.update(); });
161
213
  log.debug("store upgraded", path);
@@ -281,6 +333,13 @@ export default function (pi: ExtensionAPI) {
281
333
  shuttingDown = true;
282
334
  if (gcTimer) { clearInterval(gcTimer); gcTimer = null; }
283
335
  if (reminderTimer) { clearInterval(reminderTimer); reminderTimer = null; }
336
+ widget.deactivate();
337
+ store.setOnChange(() => {});
338
+ store.close();
339
+ if (currentWidget === widget) {
340
+ currentWidget = null;
341
+ currentStore = null;
342
+ }
284
343
  });
285
344
 
286
345
  pi.on("turn_start", async (_event: any, ctx: any) => {
@@ -314,7 +373,9 @@ export default function (pi: ExtensionAPI) {
314
373
  shuttingDown = false;
315
374
  agentBusy = false;
316
375
  log.debug("agentBusy", "session_start reset");
317
- widget.setUICtx(ctx.ui);
376
+ widget.reactivate(ctx.ui);
377
+ store.reopen();
378
+ store.setOnChange(() => { widget.update(); });
318
379
  currentUiCtx = ctx.ui;
319
380
  const reason = (event as any).reason;
320
381
  log.debug("session_start", reason);
@@ -9,6 +9,9 @@ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileS
9
9
  import { homedir } from "node:os";
10
10
  import { dirname, isAbsolute, join } from "node:path";
11
11
  import type { Task, TaskStatus, TaskStoreData } from "./types.js";
12
+ import { createLogger } from "../shared/logger.js";
13
+
14
+ const log = createLogger("pitasks");
12
15
 
13
16
  function sortById(a: Task, b: Task): number {
14
17
  return Number(a.id) - Number(b.id);
@@ -61,6 +64,7 @@ function isProcessRunning(pid: number): boolean {
61
64
 
62
65
  function normalizeTask(t: any): Task {
63
66
  const now = Date.now();
67
+ log.debug("task_normalized", { taskId: t.id, hasTelemetry: !!t.telemetry });
64
68
  return {
65
69
  ...t,
66
70
  createdBy: t.createdBy && typeof t.createdBy === "object" ? t.createdBy :
@@ -75,6 +79,13 @@ function normalizeTask(t: any): Task {
75
79
  blockedBy: Array.isArray(t.blockedBy) ? t.blockedBy : [],
76
80
  createdAt: typeof t.createdAt === "number" ? t.createdAt : now,
77
81
  updatedAt: typeof t.updatedAt === "number" ? t.updatedAt : now,
82
+ telemetry: t.telemetry && typeof t.telemetry === "object" &&
83
+ typeof t.telemetry.startedAt === "number" && typeof t.telemetry.inputTokens === "number" && typeof t.telemetry.outputTokens === "number" ? {
84
+ startedAt: t.telemetry.startedAt,
85
+ ...(typeof t.telemetry.endedAt === "number" ? { endedAt: t.telemetry.endedAt } : {}),
86
+ inputTokens: t.telemetry.inputTokens,
87
+ outputTokens: t.telemetry.outputTokens,
88
+ } : undefined,
78
89
  statusHistory: t.statusHistory && typeof t.statusHistory === "object" ? {
79
90
  pending_at: typeof t.statusHistory.pending_at === "number" ? new Date(t.statusHistory.pending_at).toISOString() : (t.statusHistory.pending_at || new Date(typeof t.createdAt === "number" ? t.createdAt : now).toISOString()),
80
91
  in_progress_at: typeof t.statusHistory.in_progress_at === "number" ? new Date(t.statusHistory.in_progress_at).toISOString() : (t.statusHistory.in_progress_at || null),
@@ -145,6 +156,10 @@ export class TaskStore {
145
156
  if (this._watcher) { this._watcher.close(); this._watcher = null; }
146
157
  }
147
158
 
159
+ reopen(): void {
160
+ if (!this._watcher) this._startWatcher();
161
+ }
162
+
148
163
  private load(): void {
149
164
  if (!this.filePath) return;
150
165
  if (!existsSync(this.filePath)) return;
@@ -258,11 +273,13 @@ export class TaskStore {
258
273
  activeForm?: string;
259
274
  owner?: string;
260
275
  metadata?: Record<string, any>;
276
+ telemetry?: Task["telemetry"];
261
277
  addBlocks?: string[];
262
278
  addBlockedBy?: string[];
263
279
  }): { task: Task | undefined; changedFields: string[]; warnings: string[] } {
264
280
  return this.withLock(() => {
265
281
  const task = this.tasks.get(id);
282
+ log.debug("task_updated", { taskId: id, status: fields.status, hasTelemetry: fields.telemetry !== undefined });
266
283
  if (!task) return { task: undefined, changedFields: [], warnings: [] };
267
284
  const changedFields: string[] = [];
268
285
  const warnings: string[] = [];
@@ -282,8 +299,17 @@ export class TaskStore {
282
299
  changedFields.push("status");
283
300
  const now = Date.now();
284
301
  const isoNow = new Date(now).toISOString();
285
- if (fields.status === "in_progress") task.statusHistory.in_progress_at = isoNow;
286
- if (fields.status === "completed") task.statusHistory.completed_at = isoNow;
302
+ if (fields.status === "in_progress") {
303
+ task.statusHistory.in_progress_at = isoNow;
304
+ if (!task.telemetry || task.telemetry.endedAt !== undefined) {
305
+ task.telemetry = { startedAt: now, inputTokens: 0, outputTokens: 0 };
306
+ }
307
+ }
308
+ if (fields.status === "completed") {
309
+ task.statusHistory.completed_at = isoNow;
310
+ if (!task.telemetry) task.telemetry = { startedAt: now, inputTokens: 0, outputTokens: 0 };
311
+ if (!task.telemetry.endedAt) task.telemetry.endedAt = now;
312
+ }
287
313
  if (fields.status === "pending") task.statusHistory.pending_at = isoNow;
288
314
  }
289
315
  if (fields.subject !== undefined) { task.subject = fields.subject; changedFields.push("subject"); }
@@ -298,6 +324,7 @@ export class TaskStore {
298
324
  }
299
325
  changedFields.push("metadata");
300
326
  }
327
+ if (fields.telemetry !== undefined) { task.telemetry = fields.telemetry; changedFields.push("telemetry"); }
301
328
 
302
329
  if (fields.addBlocks?.length) {
303
330
  for (const targetId of fields.addBlocks) {
@@ -330,6 +357,7 @@ export class TaskStore {
330
357
 
331
358
  updateTasks(updates: Array<{ id: string; fields: Record<string, any> }>): Array<{ id: string; success: boolean; changedFields?: string[] }> {
332
359
  return this.withLock(() => {
360
+ log.debug("tasks_updated", { count: updates.length, statusUpdates: updates.filter(update => update.fields.status !== undefined).length });
333
361
  const results: Array<{ id: string; success: boolean; changedFields?: string[] }> = [];
334
362
  for (const { id, fields } of updates) {
335
363
  const task = this.tasks.get(id);
@@ -350,9 +378,19 @@ export class TaskStore {
350
378
  if (fields.status !== undefined) {
351
379
  task.status = fields.status as TaskStatus;
352
380
  changedFields.push("status");
353
- const isoNow = new Date().toISOString();
354
- if (fields.status === "in_progress") task.statusHistory.in_progress_at = isoNow;
355
- if (fields.status === "completed") task.statusHistory.completed_at = isoNow;
381
+ const now = Date.now();
382
+ const isoNow = new Date(now).toISOString();
383
+ if (fields.status === "in_progress") {
384
+ task.statusHistory.in_progress_at = isoNow;
385
+ if (!task.telemetry || task.telemetry.endedAt !== undefined) {
386
+ task.telemetry = { startedAt: now, inputTokens: 0, outputTokens: 0 };
387
+ }
388
+ }
389
+ if (fields.status === "completed") {
390
+ task.statusHistory.completed_at = isoNow;
391
+ if (!task.telemetry) task.telemetry = { startedAt: now, inputTokens: 0, outputTokens: 0 };
392
+ if (!task.telemetry.endedAt) task.telemetry.endedAt = now;
393
+ }
356
394
  if (fields.status === "pending") task.statusHistory.pending_at = isoNow;
357
395
  }
358
396
  if (fields.subject !== undefined) { task.subject = fields.subject; changedFields.push("subject"); }
@@ -367,6 +405,7 @@ export class TaskStore {
367
405
  }
368
406
  changedFields.push("metadata");
369
407
  }
408
+ if (fields.telemetry !== undefined) { task.telemetry = fields.telemetry; changedFields.push("telemetry"); }
370
409
 
371
410
  if (fields.addBlocks?.length) {
372
411
  for (const targetId of fields.addBlocks) {
@@ -5,9 +5,12 @@
5
5
  */
6
6
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
7
7
  import { Type } from "typebox";
8
+ import { createLogger } from "../shared/logger.js";
8
9
  import type { TaskStore } from "./task-store.js";
9
10
  import type { TaskWidget } from "./task-widget.js";
10
11
 
12
+ const log = createLogger("pitasks");
13
+
11
14
  function textResult(msg: string) {
12
15
  return { content: [{ type: "text" as const, text: msg }], details: undefined };
13
16
  }
@@ -355,6 +358,7 @@ Set up task dependencies:
355
358
  }), { description: "Array of task updates" }),
356
359
  }),
357
360
  async execute(_callId, params) {
361
+ log.debug("tasks_bulk_updated", { count: params.updates.length });
358
362
  const store = getStore();
359
363
  const updates = params.updates.map(u => ({
360
364
  id: u.taskId,
@@ -367,6 +371,10 @@ Set up task dependencies:
367
371
  },
368
372
  }));
369
373
  const results = store.updateTasks(updates);
374
+ for (const update of updates) {
375
+ if (update.fields.status === "in_progress") widget.setActiveTask(update.id);
376
+ else if (update.fields.status !== undefined) widget.setActiveTask(update.id, false);
377
+ }
370
378
  widget.update();
371
379
  const lines = results.map(r => r.success ? `#${r.id}: ${r.changedFields?.join(", ") || "no changes"}` : `#${r.id}: not found`);
372
380
  return textResult(`Updated ${results.filter(r => r.success).length} task(s):\n${lines.join("\n")}`);
@@ -3,8 +3,11 @@
3
3
  * Copied from @tintinweb/pi-tasks (MIT license).
4
4
  */
5
5
  import { truncateToWidth } from "@earendil-works/pi-tui";
6
+ import { createLogger } from "../shared/logger.js";
6
7
  import type { TaskStore } from "./task-store.js";
7
8
 
9
+ const log = createLogger("pitasks");
10
+
8
11
  const SPINNER = ["✳", "✴", "✵", "✶", "✷", "✸", "✹", "✺", "✻", "✼", "✽"];
9
12
  const DEFAULT_MAX_VISIBLE_TASKS = 10;
10
13
 
@@ -36,29 +39,61 @@ export class TaskWidget {
36
39
  private metrics = new Map<string, { startedAt: number; inputTokens: number; outputTokens: number }>();
37
40
  private tui: any;
38
41
  private widgetRegistered = false;
42
+ private disposed = false;
39
43
 
40
- constructor(private store: TaskStore, private config: Record<string, any> = {}) {}
44
+ constructor(private store: TaskStore, private config: Record<string, any> = {}) {
45
+ log.debug("widget_created", { taskCount: store.list().length });
46
+ }
41
47
 
42
- setStore(store: TaskStore): void { this.store = store; }
48
+ setStore(store: TaskStore): void {
49
+ this.store = store;
50
+ this.activeTaskIds.clear();
51
+ this.metrics.clear();
52
+ log.debug("widget_store_set", { taskCount: store.list().length });
53
+ }
43
54
  setUICtx(ctx: any): void { this.uiCtx = ctx; }
55
+ getActiveTaskIds(): string[] { return [...this.activeTaskIds]; }
56
+
57
+ reactivate(ctx: any): void {
58
+ this.disposed = false;
59
+ this.uiCtx = ctx;
60
+ log.debug("widget_reactivated", { taskCount: this.store.list().length });
61
+ }
62
+
63
+ deactivate(): void {
64
+ log.debug("widget_deactivated", { hadWidget: this.widgetRegistered, hadTimer: !!this.widgetInterval });
65
+ if (this.widgetInterval) { clearInterval(this.widgetInterval); this.widgetInterval = undefined; }
66
+ if (this.uiCtx) this.uiCtx.setWidget("tasks", undefined);
67
+ this.widgetRegistered = false;
68
+ this.tui = undefined;
69
+ this.uiCtx = undefined;
70
+ }
44
71
 
45
72
  setActiveTask(taskId: string, active = true): void {
46
- if (taskId && active) {
73
+ const task = this.store.get(taskId);
74
+ log.debug("task_telemetry_active", { taskId, active, status: task?.status });
75
+ if (taskId && active && task) {
47
76
  this.activeTaskIds.add(taskId);
48
- if (!this.metrics.has(taskId)) {
49
- this.metrics.set(taskId, { startedAt: Date.now(), inputTokens: 0, outputTokens: 0 });
50
- }
77
+ const telemetry = task.telemetry ?? { startedAt: Date.now(), inputTokens: 0, outputTokens: 0 };
78
+ if (!task.telemetry) this.store.update(taskId, { telemetry });
79
+ this.metrics.set(taskId, telemetry);
51
80
  this.ensureTimer();
52
81
  } else if (taskId) {
53
82
  this.activeTaskIds.delete(taskId);
83
+ if (task?.telemetry && !task.telemetry.endedAt) this.store.update(taskId, { telemetry: { ...task.telemetry, endedAt: Date.now() } });
54
84
  }
55
85
  this.update();
56
86
  }
57
87
 
58
88
  addTokenUsage(inputTokens: number, outputTokens: number): void {
89
+ log.debug("task_telemetry_usage", { activeTaskCount: this.activeTaskIds.size, inputTokens, outputTokens });
59
90
  for (const id of this.activeTaskIds) {
60
91
  const m = this.metrics.get(id);
61
- if (m) { m.inputTokens += inputTokens; m.outputTokens += outputTokens; }
92
+ if (m) {
93
+ m.inputTokens += inputTokens;
94
+ m.outputTokens += outputTokens;
95
+ this.store.update(id, { telemetry: m });
96
+ }
62
97
  }
63
98
  }
64
99
 
@@ -69,6 +104,7 @@ export class TaskWidget {
69
104
  }
70
105
 
71
106
  private renderWidget(tui: any, theme: any): string[] {
107
+ log.debug("task_widget_render", { taskCount: this.store.list().length });
72
108
  try { return this.buildWidgetLines(tui, theme); } catch { return []; }
73
109
  }
74
110
 
@@ -114,29 +150,23 @@ export class TaskWidget {
114
150
  if (openBlockers.length > 0) suffix = theme.fg("dim", ` › blocked by ${openBlockers.map((id: string) => "#" + id).join(", ")}`);
115
151
  }
116
152
 
153
+ const m = task.telemetry ?? this.metrics.get(task.id);
154
+ const stats = m ? (() => {
155
+ const elapsed = formatDuration(Math.max(0, (m.endedAt ?? Date.now()) - m.startedAt));
156
+ const tokenParts = [m.inputTokens > 0 && `↑ ${formatTokens(m.inputTokens)}`, m.outputTokens > 0 && `↓ ${formatTokens(m.outputTokens)}`].filter(Boolean);
157
+ return ` ${theme.fg("dim", tokenParts.length ? `(${elapsed} · ${tokenParts.join(" ")})` : `(${elapsed})`)}`;
158
+ })() : "";
159
+
117
160
  let text: string;
118
161
  if (task.status === "in_progress") {
119
162
  const form = task.activeForm || task.subject;
120
163
  const agentId = task.metadata?.agentId;
121
164
  const agentLabel = agentId ? ` (agent ${agentId.slice(0, 5)})` : "";
122
- const m = this.metrics.get(task.id);
123
- let stats = "";
124
- if (m) {
125
- const elapsed = formatDuration(Date.now() - m.startedAt);
126
- const tokenParts: string[] = [];
127
- if (m.inputTokens > 0) tokenParts.push(`↑ ${formatTokens(m.inputTokens)}`);
128
- if (m.outputTokens > 0) tokenParts.push(`↓ ${formatTokens(m.outputTokens)}`);
129
- stats = tokenParts.length > 0
130
- ? ` ${theme.fg("dim", `(${elapsed} · ${tokenParts.join(" ")})`)}`
131
- : ` ${theme.fg("dim", `(${elapsed})`)}`;
132
- }
133
165
  text = ` ${icon} ${theme.fg("dim", "#" + task.id)} ${theme.fg("accent", form + agentLabel + "…")}${stats}`;
134
166
  } else if (task.status === "completed") {
135
- text = ` ${icon} ${theme.fg("dim", theme.strikethrough("#" + task.id + " " + task.subject))}`;
167
+ text = ` ${icon} ${theme.fg("dim", theme.strikethrough("#" + task.id + " " + task.subject))}${stats}`;
136
168
  } else {
137
- const agentSuffix = task.status === "in_progress" && task.metadata?.agentId
138
- ? theme.fg("dim", ` (agent ${task.metadata.agentId.slice(0, 5)})`) : "";
139
- text = ` ${icon} ${theme.fg("dim", "#" + task.id)} ${task.subject}${agentSuffix}`;
169
+ text = ` ${icon} ${theme.fg("dim", "#" + task.id)} ${task.subject}${stats}`;
140
170
  }
141
171
  lines.push(truncate(text + suffix));
142
172
  }
@@ -146,7 +176,8 @@ export class TaskWidget {
146
176
  }
147
177
 
148
178
  update(): void {
149
- if (!this.uiCtx) return;
179
+ log.debug("widget_update", { disposed: this.disposed, hasUi: !!this.uiCtx, taskCount: this.store.list().length });
180
+ if (this.disposed || !this.uiCtx) return;
150
181
  const tasks = this.store.list();
151
182
  if (tasks.length === 0) {
152
183
  if (this.widgetRegistered) { this.uiCtx.setWidget("tasks", undefined); this.widgetRegistered = false; }
@@ -173,9 +204,8 @@ export class TaskWidget {
173
204
  }
174
205
 
175
206
  dispose(): void {
176
- if (this.widgetInterval) { clearInterval(this.widgetInterval); this.widgetInterval = undefined; }
177
- if (this.uiCtx) this.uiCtx.setWidget("tasks", undefined);
178
- this.widgetRegistered = false;
179
- this.tui = undefined;
207
+ log.debug("widget_disposed", { hadWidget: this.widgetRegistered, hadTimer: !!this.widgetInterval });
208
+ this.disposed = true;
209
+ this.deactivate();
180
210
  }
181
211
  }
@@ -23,6 +23,12 @@ export interface Task {
23
23
  };
24
24
  createdAt: number;
25
25
  updatedAt: number;
26
+ telemetry?: {
27
+ startedAt: number;
28
+ endedAt?: number;
29
+ inputTokens: number;
30
+ outputTokens: number;
31
+ };
26
32
  statusHistory: {
27
33
  pending_at: string;
28
34
  in_progress_at: string | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-orchestrator-config",
3
- "version": "4.3.14",
3
+ "version": "4.4.0",
4
4
  "description": "Orchestrator pattern for pi: specialist subagents, enforcement, code review loop, workflow commands",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -39,6 +39,7 @@
39
39
  "extensions/pidiff/pidiff-ui/dist/",
40
40
  "extensions/pitasks/",
41
41
  "scripts/pidash-server.ts",
42
+ "scripts/pidash-session-state.ts",
42
43
  "scripts/pidiff-server.ts",
43
44
  "scripts/pidiff-git-ignore.ts",
44
45
  "scripts/daemon-shared.ts",
@@ -15,7 +15,8 @@
15
15
  | Jenkins/CI/Groovy | `jenkins-expert` |
16
16
  | Git operations (local) | `git-expert` |
17
17
  | GitHub (PRs, issues, releases, workflows) | `github-expert` |
18
- | Tests | `test-automator` |
18
+ | Running existing tests / analyzing failures | `test-runner` |
19
+ | Creating or modifying tests / test strategy | `test-automator` |
19
20
  | Debugging | `debugger` |
20
21
  | API docs | `api-documenter` |
21
22
  | External repo security audit | `security-auditor` |
@@ -26,8 +27,10 @@
26
27
 
27
28
  Route based on the task intent, not just the tool being used.
28
29
 
29
- - Running Python tests? → `python-expert` (not bash-expert)
30
- - Editing Python files? → `python-expert` (even with sed/awk)
30
+ - Running existing tests (including Python) or analyzing failures? → `test-runner`
31
+ - Fixing Python production code after test failures? → `python-expert`
32
+ - Creating or changing Python tests? → `test-automator`
33
+ - Editing Python production files? → `python-expert` (even with sed/awk)
31
34
  - Creating a PR? → `github-expert` (not git-expert)
32
35
  - External library docs? → `docs-fetcher` (not direct fetch)
33
36
 
@@ -4,7 +4,7 @@
4
4
 
5
5
  ## Mandatory review loop
6
6
 
7
- After ANY code change, send to ALL 6 agents (5 reviewers + test-automator) IN PARALLEL. **Never skip the first review.**
7
+ After ANY code change, send to ALL 6 agents (5 reviewers + test-runner) IN PARALLEL. **Never skip the first review.**
8
8
 
9
9
  ## Commit enforcement
10
10
 
@@ -12,7 +12,7 @@ After ANY code change, send to ALL 6 agents (5 reviewers + test-automator) IN PA
12
12
  When `review_loop_enforcement` is enabled, the enforcement rule in `enforcement.ts` blocks `git commit` unless:
13
13
 
14
14
  - Review status is `clean` (all reviewers returned 0 findings)
15
- - `tests_passed: true` (test-automator or test command succeeded)
15
+ - `tests_passed: true` (a detected test command, including one run by `test-runner`, succeeded)
16
16
 
17
17
  **You MUST run the review loop BEFORE attempting `git commit`.** The enforcement rule will reject the commit otherwise.
18
18
  Do NOT try to work around it — run the actual review agents to reach `clean` status.
@@ -52,7 +52,7 @@ next resolution layer / default `3` — see `dev-docs/project-settings.md` for t
52
52
  Disable the review loop via `review_loop_enforcement: false` — not via max_cycles.
53
53
 
54
54
  Cap check is after 5a; it only blocks `go to 2` (re-dispatch of step 2 / all 6 agents,
55
- including test-automator), not completing the current cycle's fix/explain.
55
+ including test-runner), not completing the current cycle's fix/explain.
56
56
 
57
57
  **After 5a completes on the max cycle, two outcomes** (report them; commit may still be
58
58
  allowed via the max-cycle path — see step 6):
@@ -71,7 +71,7 @@ cycle3: dispatch → findings → 5a (fix|explain) → cycle >= max → stop (no
71
71
 
72
72
  ```text
73
73
  1. Specialist writes/fixes code
74
- 2. Send ALL 6 agents IN PARALLEL (async): 5 reviewers + test-automator
74
+ 2. Send ALL 6 agents IN PARALLEL (async): 5 reviewers + test-runner
75
75
  **On cycle 2+: MANDATORY — include prior cycle findings + responses in each reviewer's prompt (see Step 5a format).**
76
76
  Reviewers without this context will blindly repeat the same findings. This is not optional.
77
77
  3. Wait for all 6 to complete
@@ -94,7 +94,7 @@ The enforcement rule blocks commits until this is satisfied — if it blocks you
94
94
  **After a cap stop:** report the two-outcome result (**Not fixed** → outstanding, **Fixed** → verification
95
95
  blocked); commit is allowed when status is `has_findings` or `clean`, no reviewers pending, and
96
96
  `cycle >= review_loop_max_cycles` (no `tests_passed` requirement); do **not**
97
- return to step 2 (re-dispatch of all 6 agents, including test-automator).
97
+ return to step 2 (re-dispatch of all 6 agents, including test-runner).
98
98
  {{/IF}}
99
99
 
100
100
  ## Review Agents
@@ -108,7 +108,7 @@ Six agents run in parallel for comprehensive coverage:
108
108
  | `code-reviewer-security` | Bugs, logic errors, and security vulnerabilities |
109
109
  | `code-reviewer-docs` | Documentation quality, completeness, and accuracy |
110
110
  | `code-reviewer-spec` | Code/PR/issue spec alignment and compliance |
111
- | `test-automator` | Run project tests (pytest, node tests, pre-commit) |
111
+ | `test-runner` | Run project tests (pytest, node tests, pre-commit) |
112
112
 
113
113
  **All 6 MUST be invoked as async subagents (`async: true`) in the same assistant turn.
114
114
  Do NOT block waiting for results — continue working while they run.**
@@ -171,7 +171,7 @@ Findings that were fixed in code → verify the fix, do not re-raise if correct.
171
171
 
172
172
  ## Key Rules
173
173
 
174
- Never skip code review — all 6 agents always run (5 reviewers + test-automator).
174
+ Never skip code review — all 6 agents always run (5 reviewers + test-runner).
175
175
  Exception: on `chore/bump-version` branches (release version bumps), review dirty-tracking and commit blocking are skipped.
176
176
  {{IF:review_loop_enforcement}}
177
177
  When `review_loop_enforcement` is enabled: loop, respond to each finding (fix or explain), and re-run all 6 from step 2
@@ -197,12 +197,12 @@ Tests still run and `tests_passed` is still recorded; commit is not blocked by r
197
197
 
198
198
  - **Bash hook detection:** When any agent runs a test command (`pytest`, `npm test`, `npx tsx --test`, `tox`, `go test`, `vitest`, `jest`, `mocha`),
199
199
  the enforcement hook detects it and auto-marks `tests_passed` based on exit code.
200
- - **Agent completion:** When `test-automator` or `test-runner` agents complete, their result auto-marks `tests_passed`.
200
+ - **Async test agents:** `test-runner` and `test-automator` completion never changes `tests_passed`; agent process completion does not prove its test command passed.
201
201
  - **Reset on edit:** Any file edit triggers `markNeedsReview()` (except on `chore/bump-version` branches), which resets `tests_passed: false` —
202
202
  preventing stale results.
203
203
 
204
204
  **Duplicate test run avoidance:** If a specialist already ran tests before the review loop, `tests_passed` may already be `true`.
205
- Any file edit resets it, so test-automator in the parallel batch always validates the latest code.
205
+ Any file edit resets it, so test-runner in the parallel batch always validates the latest code.
206
206
 
207
207
  ## Baseline Test Comparison (Step 5)
208
208
 
@@ -0,0 +1,106 @@
1
+ import type { SessionInfo } from "../extensions/shared/types.ts";
2
+ import { initialActivityState } from "../extensions/pidash/activity-state.ts";
3
+ import { createLogger } from "../extensions/shared/logger.ts";
4
+
5
+ const log = createLogger("pidash");
6
+
7
+ export interface PiClient {
8
+ ws: any;
9
+ session: SessionInfo;
10
+ eventBuffer: string[];
11
+ replaying: boolean;
12
+ connectionGeneration: number;
13
+ }
14
+
15
+ function registeredSession(parsed: any): SessionInfo {
16
+ const activity = parsed.activity === "working" || parsed.activity === "waiting_for_input" ? parsed.activity : "idle";
17
+ const streaming = typeof parsed.streaming === "boolean" ? parsed.streaming : false;
18
+ return {
19
+ sessionId: parsed.sessionId || `${parsed.pid}:${parsed.cwd}`,
20
+ pid: parsed.pid,
21
+ cwd: parsed.cwd || "",
22
+ branch: parsed.branch || "",
23
+ model: parsed.model || "",
24
+ startedAt: parsed.startedAt || new Date().toISOString(),
25
+ lastActivity: Date.now(),
26
+ active: true,
27
+ sessionFile: parsed.sessionFile || "",
28
+ gitDirty: parsed.gitDirty || false,
29
+ gitChanges: parsed.gitChanges || 0,
30
+ container: parsed.container || false,
31
+ contextWindow: parsed.contextWindow || 0,
32
+ diffPort: parsed.diffPort || null,
33
+ thinkingLevel: parsed.thinkingLevel || "medium",
34
+ name: parsed.name || undefined,
35
+ comsName: parsed.comsName || undefined,
36
+ comsPurpose: parsed.comsPurpose || undefined,
37
+ comsProject: parsed.comsProject || undefined,
38
+ ...initialActivityState(),
39
+ activity,
40
+ activitySequence: Number.isSafeInteger(parsed.activitySequence) ? parsed.activitySequence : 0,
41
+ activityBeforePrompt: parsed.activityBeforePrompt === "working" || parsed.activityBeforePrompt === "waiting_for_input" || parsed.activityBeforePrompt === "idle" ? parsed.activityBeforePrompt : undefined,
42
+ streaming,
43
+ working: activity === "working",
44
+ };
45
+ }
46
+
47
+ export function registerPidashSession(
48
+ sessions: Map<string, PiClient>,
49
+ ws: any,
50
+ parsed: any,
51
+ broadcast: (event: object) => void,
52
+ ): PiClient {
53
+ const session = registeredSession(parsed);
54
+ const existing = sessions.get(session.sessionId);
55
+ let client: PiClient;
56
+ if (existing) {
57
+ if (!session.name && existing.session.name) session.name = existing.session.name;
58
+ if (!session.comsName && existing.session.comsName) session.comsName = existing.session.comsName;
59
+ if (!session.comsPurpose && existing.session.comsPurpose) session.comsPurpose = existing.session.comsPurpose;
60
+ if (!session.comsProject && existing.session.comsProject) session.comsProject = existing.session.comsProject;
61
+ existing.ws = ws;
62
+ existing.session = session;
63
+ existing.eventBuffer = [];
64
+ existing.replaying = true;
65
+ existing.connectionGeneration++;
66
+ client = existing;
67
+ } else {
68
+ client = { ws, session, eventBuffer: [], replaying: true, connectionGeneration: 1 };
69
+ }
70
+ sessions.set(session.sessionId, client);
71
+ log.info(`session registered: session=${session.sessionId} streaming=${session.streaming}`);
72
+ broadcast({ type: "session_added", session });
73
+ return client;
74
+ }
75
+
76
+ export function disconnectPidashSession(
77
+ client: PiClient,
78
+ ws: any,
79
+ source: "close" | "error",
80
+ broadcast: (event: object) => void,
81
+ ): void {
82
+ if (client.ws !== ws) {
83
+ log.debug(`stale connection ${source} ignored: session=${client.session.sessionId}`);
84
+ return;
85
+ }
86
+ client.session.active = false;
87
+ client.session.activity = "idle";
88
+ client.session.activityBeforePrompt = undefined;
89
+ client.session.streaming = false;
90
+ client.session.working = false;
91
+ client.ws = null;
92
+ log.info(`session disconnected: session=${client.session.sessionId} source=${source}`);
93
+ broadcast({ type: "session_updated", session: client.session });
94
+ }
95
+
96
+ export function createPidashSessionState(
97
+ broadcast: (event: object) => void,
98
+ sessions = new Map<string, PiClient>(),
99
+ ) {
100
+ log.debug(`session state created: sessions=${sessions.size}`);
101
+ return {
102
+ sessions,
103
+ register: (ws: any, parsed: any) => registerPidashSession(sessions, ws, parsed, broadcast),
104
+ disconnect: (client: PiClient, ws: any, source: "close" | "error") => disconnectPidashSession(client, ws, source, broadcast),
105
+ };
106
+ }