@mjasnikovs/pi-task 0.42.1 → 0.42.2

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/dist/index.js CHANGED
@@ -7,6 +7,7 @@ import { registerRemote } from './remote/register.js';
7
7
  import { registerCommandWatchdog } from './task/command-watchdog.js';
8
8
  import { registerStreamWatchdog } from './task/stream-watchdog.js';
9
9
  import { registerImplementationGuards } from './task/implementation-guards.js';
10
+ import { registerTaskDirCustody } from './task/task-dir-custody.js';
10
11
  import { registerModelHoldRestore } from './task/model-hold-stash.js';
11
12
  export default function (pi) {
12
13
  registerConfig(pi);
@@ -18,5 +19,6 @@ export default function (pi) {
18
19
  registerCommandWatchdog(pi);
19
20
  registerStreamWatchdog(pi);
20
21
  registerImplementationGuards(pi);
22
+ registerTaskDirCustody(pi);
21
23
  registerModelHoldRestore(pi);
22
24
  }
@@ -27,6 +27,7 @@
27
27
  import { formatLoopHint } from './loop-detector.js';
28
28
  import { classifyEnforceChildFailure } from './enforce-guidelines.js';
29
29
  import { notifyRun } from '../remote/bridge.js';
30
+ import { restoreTaskDir, snapshotTaskDir, taskDirRestoredNotice } from './task-dir-custody.js';
30
31
  /**
31
32
  * What each kind may do. Adding a child is a row; it cannot be added without
32
33
  * deciding all four questions, which is the point.
@@ -69,6 +70,9 @@ export function makeGateChild(deps) {
69
70
  const log = deps.makeDebugAppender(deps.logPath);
70
71
  log(`=== ${deps.kind} start: ${deps.taskTitle} ===`);
71
72
  const guardSnapshot = row.guarded ? await deps.captureGitState(deps.cwd, sig) : null;
73
+ // Every kind, whatever its tools: the git-state guard and the discard both
74
+ // leave `.pi-tasks` alone.
75
+ const taskDir = await snapshotTaskDir(deps.cwd);
72
76
  const frame = deps.loader === false ?
73
77
  null
74
78
  : () => ({
@@ -132,6 +136,12 @@ export function makeGateChild(deps) {
132
136
  finally {
133
137
  // Restore whatever the child moved BEFORE any verdict or failure is
134
138
  // acted on — a crashed child must not skip the restore either.
139
+ const restored = await restoreTaskDir(deps.cwd, taskDir);
140
+ if (restored.length > 0) {
141
+ log(`=== ${deps.kind} TASK-DIR CUSTODY — restored: ${restored.join(', ')} ===`);
142
+ notifyRun(deps.ctx, `${deps.taskTitle}: `
143
+ + taskDirRestoredNotice(`the ${deps.kind} child`, restored), 'warning');
144
+ }
135
145
  if (guardSnapshot) {
136
146
  const rec = await deps.reconcileGitState(deps.cwd, guardSnapshot, sig);
137
147
  deps.onReconcile?.(rec);
@@ -16,10 +16,6 @@ export declare function implementationGuardArmed(): boolean;
16
16
  * one altered byte is a new key and a clean slate on both counters.
17
17
  */
18
18
  export declare function blockedCallReason(toolName: string, count: number): string;
19
- /** Every file under the task dir is host-written. MEASURED: an mx5 implementer
20
- * wrote its report over TASK_AUTO_0001.md, and the run died on its front matter. */
21
- export declare function targetsTaskDir(toolName: string, input: unknown): boolean;
22
- export declare function taskDirWriteReason(): string;
23
19
  /** The reason on the final block, which also ends the turn. */
24
20
  export declare function terminalCallReason(): string;
25
21
  export declare function consumeGuardTermination(): boolean;
@@ -1,5 +1,4 @@
1
1
  import { LoopDetector, loopKey, LOOP_THRESHOLD, LOOP_WINDOW, MAX_LOOP_RESTARTS } from './loop-detector.js';
2
- import { TASKS_DIR_NAME } from './task-types.js';
3
2
  /**
4
3
  * Runaway guard for the IMPLEMENTATION TURN — the one model surface with none.
5
4
  *
@@ -82,18 +81,6 @@ export function blockedCallReason(toolName, count) {
82
81
  return (`Blocked: this is the ${count}th identical ${toolName} call in this turn. `
83
82
  + `Use what you already have, or do something different, then continue the task.`);
84
83
  }
85
- /** Every file under the task dir is host-written. MEASURED: an mx5 implementer
86
- * wrote its report over TASK_AUTO_0001.md, and the run died on its front matter. */
87
- export function targetsTaskDir(toolName, input) {
88
- if (!MUTATING_TOOLS.has(toolName))
89
- return false;
90
- const target = input?.path;
91
- return typeof target === 'string' && target.split(/[\\/]/).includes(TASKS_DIR_NAME);
92
- }
93
- export function taskDirWriteReason() {
94
- return (`Blocked: ${TASKS_DIR_NAME}/ belongs to pi-task and is written only by the host. `
95
- + `Put your report in your reply, not in a file, then continue the task.`);
96
- }
97
84
  /** The reason on the final block, which also ends the turn. */
98
85
  export function terminalCallReason() {
99
86
  return (`Blocked: this turn repeated one call past every warning, so it is being stopped `
@@ -133,9 +120,6 @@ export function registerImplementationGuards(pi) {
133
120
  if (state.terminating) {
134
121
  return { block: true, terminate: true, reason: terminalCallReason() };
135
122
  }
136
- if (targetsTaskDir(event.toolName, event.input)) {
137
- return { block: true, reason: taskDirWriteReason() };
138
- }
139
123
  const call = { name: event.toolName, args: event.input };
140
124
  const mutating = MUTATING_TOOLS.has(event.toolName);
141
125
  const hit = mutating ? state.edits.record(call) : state.loop.record(call);
@@ -1,8 +1,8 @@
1
1
  /**
2
- * The implementation-turn bracket: one entry arms both the status widget and the
3
- * runaway guard, one `leave` disarms both.
2
+ * The implementation-turn bracket: one entry arms the status widget and the
3
+ * runaway guard and takes custody of the task dir, one `leave` ends all three.
4
4
  *
5
- * The two modules keep their own lifecycle handlers, and they draw the turn
5
+ * Each module keeps its own lifecycle handlers, and the first two draw the turn
6
6
  * boundary differently on purpose: the widget hides on `agent_end` (a sub-turn is
7
7
  * over, the screen should say so), the guard survives until `agent_settled`
8
8
  * (compactions and retries fire `agent_end` INSIDE a turn — see its header). What
@@ -13,12 +13,14 @@
13
13
  import { type ImplWidgetMeta } from './impl-widget.js';
14
14
  /**
15
15
  * `oneShot` true (fire-and-forget /task) lets each module's own settle handler
16
- * disarm after the single turn; false (awaited /task-auto) keeps both armed
16
+ * end it after the single turn; false (awaited /task-auto) keeps all three
17
17
  * across resume and steer turns until `leave` is called.
18
18
  *
19
19
  * `leave` is idempotent: a second call is a no-op, so a caller can put it in a
20
- * `finally` and a `catch` without disarming a bracket entered since.
20
+ * `finally` and a `catch` without ending a bracket entered since. It resolves to
21
+ * the task files it restored.
21
22
  */
22
23
  export declare function enterImplementationTurn(meta: ImplWidgetMeta, opts: {
23
24
  oneShot: boolean;
24
- }): () => void;
25
+ cwd: string;
26
+ }): Promise<() => Promise<string[]>>;
@@ -1,8 +1,8 @@
1
1
  /**
2
- * The implementation-turn bracket: one entry arms both the status widget and the
3
- * runaway guard, one `leave` disarms both.
2
+ * The implementation-turn bracket: one entry arms the status widget and the
3
+ * runaway guard and takes custody of the task dir, one `leave` ends all three.
4
4
  *
5
- * The two modules keep their own lifecycle handlers, and they draw the turn
5
+ * Each module keeps its own lifecycle handlers, and the first two draw the turn
6
6
  * boundary differently on purpose: the widget hides on `agent_end` (a sub-turn is
7
7
  * over, the screen should say so), the guard survives until `agent_settled`
8
8
  * (compactions and retries fire `agent_end` INSIDE a turn — see its header). What
@@ -12,23 +12,28 @@
12
12
  */
13
13
  import { armImplWidget, disarmImplWidget } from './impl-widget.js';
14
14
  import { armImplementationGuard, disarmImplementationGuard } from './implementation-guards.js';
15
+ import { takeTaskDirCustody } from './task-dir-custody.js';
15
16
  /**
16
17
  * `oneShot` true (fire-and-forget /task) lets each module's own settle handler
17
- * disarm after the single turn; false (awaited /task-auto) keeps both armed
18
+ * end it after the single turn; false (awaited /task-auto) keeps all three
18
19
  * across resume and steer turns until `leave` is called.
19
20
  *
20
21
  * `leave` is idempotent: a second call is a no-op, so a caller can put it in a
21
- * `finally` and a `catch` without disarming a bracket entered since.
22
+ * `finally` and a `catch` without ending a bracket entered since. It resolves to
23
+ * the task files it restored.
22
24
  */
23
- export function enterImplementationTurn(meta, opts) {
24
- armImplWidget(meta, opts);
25
- armImplementationGuard(opts);
25
+ export async function enterImplementationTurn(meta, opts) {
26
+ const { oneShot } = opts;
27
+ const releaseTaskDir = await takeTaskDirCustody(opts.cwd, { oneShot });
28
+ armImplWidget(meta, { oneShot });
29
+ armImplementationGuard({ oneShot });
26
30
  let left = false;
27
- return () => {
31
+ return async () => {
28
32
  if (left)
29
- return;
33
+ return [];
30
34
  left = true;
31
35
  disarmImplWidget();
32
36
  disarmImplementationGuard();
37
+ return releaseTaskDir();
33
38
  };
34
39
  }
@@ -178,6 +178,7 @@ export declare class TaskRunner {
178
178
  */
179
179
  private _recordHandoff;
180
180
  private _deliverSpec;
181
+ private _reportRestored;
181
182
  /**
182
183
  * The spec as the implementer should receive it (Layer B). Layer A strips phantom
183
184
  * specifiers from the upstream pipeline text, but a residual affirmative can survive
@@ -27,7 +27,8 @@ import { allocateTaskId, ensureTasksDir, mergeTaskSection, readSection, readTask
27
27
  import { startWidget } from './widget.js';
28
28
  import { setupImplWidget } from './impl-widget.js';
29
29
  import { enterImplementationTurn } from './implementation-scope.js';
30
- import { SessionUI, publishNotify, registerBridgeCommand, getBridge, notifyBoth, isRemoteOrigin } from '../remote/bridge.js';
30
+ import { taskDirRestoredNotice } from './task-dir-custody.js';
31
+ import { SessionUI, publishNotify, registerBridgeCommand, getBridge, notifyBoth, notifyRun, isRemoteOrigin } from '../remote/bridge.js';
31
32
  import { pushNotify } from '../remote/push.js';
32
33
  import { getConfig } from '../config/config.js';
33
34
  import { appendDebugLine, gateDebugWriter } from './debug-log.js';
@@ -473,7 +474,10 @@ export class TaskRunner {
473
474
  label: this._widgetState.label
474
475
  };
475
476
  if (this._sendSpec) {
476
- const leave = enterImplementationTurn(meta, { oneShot: !this._implAwaited });
477
+ const leave = await enterImplementationTurn(meta, {
478
+ oneShot: !this._implAwaited,
479
+ cwd: this._cwd
480
+ });
477
481
  let delivered = false;
478
482
  try {
479
483
  await this._sendSpec(spec);
@@ -486,14 +490,14 @@ export class TaskRunner {
486
490
  // missing model, or a failed auth. The next unrelated turn would
487
491
  // inherit it, and this guard can end a turn outright.
488
492
  if (this._implAwaited || !delivered)
489
- leave();
493
+ this._reportRestored(await leave());
490
494
  }
491
495
  return;
492
496
  }
493
497
  if (!piApi) {
494
498
  throw new Error('extension not initialised (no ExtensionAPI captured)');
495
499
  }
496
- const leave = enterImplementationTurn(meta, { oneShot: true });
500
+ const leave = await enterImplementationTurn(meta, { oneShot: true, cwd: this._cwd });
497
501
  // Same reason as the awaited path's `delivered` flag: this send can throw
498
502
  // SYNCHRONOUSLY — the loader gates every ExtensionAPI action behind
499
503
  // `assertActive()` — and a guard left armed over a turn that never starts
@@ -507,10 +511,17 @@ export class TaskRunner {
507
511
  piApi.sendUserMessage(spec, { deliverAs: 'followUp' });
508
512
  }
509
513
  catch (e) {
510
- leave();
514
+ await leave();
511
515
  throw e;
512
516
  }
513
517
  }
518
+ _reportRestored(names) {
519
+ if (names.length === 0)
520
+ return;
521
+ const notice = taskDirRestoredNotice('The implementation turn', names);
522
+ this._deps.logDebug?.(notice);
523
+ notifyRun(this._ctx, notice, 'warning');
524
+ }
514
525
  /**
515
526
  * The spec as the implementer should receive it (Layer B). Layer A strips phantom
516
527
  * specifiers from the upstream pipeline text, but a residual affirmative can survive
@@ -0,0 +1,24 @@
1
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
2
+ /** File name → bytes, for the held files only. */
3
+ export type TaskDirSnapshot = ReadonlyMap<string, Buffer>;
4
+ export declare function snapshotTaskDir(cwd: string): Promise<TaskDirSnapshot>;
5
+ /** Rewrite every snapshotted file that changed or is gone. Never throws.
6
+ * Returns the names it put back, sorted. */
7
+ export declare function restoreTaskDir(cwd: string, snapshot: TaskDirSnapshot): Promise<string[]>;
8
+ export declare function taskDirRestoredNotice(who: string, names: readonly string[]): string;
9
+ /**
10
+ * Snapshot before the turn can start, replacing any custody still held.
11
+ * `oneShot` means the settle ends it; otherwise the returned release does, and
12
+ * it never releases a custody taken since.
13
+ */
14
+ export declare function takeTaskDirCustody(cwd: string, opts: {
15
+ oneShot: boolean;
16
+ }): Promise<() => Promise<string[]>>;
17
+ export declare function releaseTaskDirCustody(): Promise<string[]>;
18
+ /** @internal Test seam: is a turn's task dir held? */
19
+ export declare function taskDirCustodyHeld(): boolean;
20
+ /**
21
+ * pi awaits these handlers before it wakes a command waiting for idle, so a
22
+ * one-shot restore finishes before the next run writes anything.
23
+ */
24
+ export declare function registerTaskDirCustody(pi: ExtensionAPI): void;
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Custody of the task dir while a model can write to it.
3
+ *
4
+ * Every markdown file in `.pi-tasks/` is host state. A model reaches it through
5
+ * edit, write or bash, and pi rewrites a path before it writes (`@x`, `file://`),
6
+ * so a rule on the tool call sees only some of the writers. The host snapshots
7
+ * the files before the model runs and puts back whatever changed. MEASURED: an
8
+ * mx5 implementer wrote its report over TASK_AUTO_0001.md, and the run died on
9
+ * its front matter.
10
+ *
11
+ * Only markdown is held: pi-task's own tools write research-cache.json mid-turn.
12
+ * A file created in the window is kept, because a run started meanwhile
13
+ * allocates its own.
14
+ */
15
+ import * as fsp from 'node:fs/promises';
16
+ import * as path from 'node:path';
17
+ import { notifyRun } from '../remote/bridge.js';
18
+ import { tasksDir } from './task-io.js';
19
+ import { TASKS_DIR_NAME } from './task-types.js';
20
+ export async function snapshotTaskDir(cwd) {
21
+ const dir = tasksDir(cwd);
22
+ const out = new Map();
23
+ const entries = await fsp.readdir(dir, { withFileTypes: true }).catch(() => []);
24
+ for (const e of entries) {
25
+ if (!e.isFile() || !e.name.endsWith('.md'))
26
+ continue;
27
+ const bytes = await fsp.readFile(path.join(dir, e.name)).catch(() => null);
28
+ if (bytes)
29
+ out.set(e.name, bytes);
30
+ }
31
+ return out;
32
+ }
33
+ /** Rewrite every snapshotted file that changed or is gone. Never throws.
34
+ * Returns the names it put back, sorted. */
35
+ export async function restoreTaskDir(cwd, snapshot) {
36
+ const dir = tasksDir(cwd);
37
+ const restored = [];
38
+ for (const [name, bytes] of snapshot) {
39
+ const file = path.join(dir, name);
40
+ const now = await fsp.readFile(file).catch(() => null);
41
+ if (now?.equals(bytes))
42
+ continue;
43
+ try {
44
+ await fsp.mkdir(dir, { recursive: true });
45
+ await fsp.writeFile(file, bytes);
46
+ restored.push(name);
47
+ }
48
+ catch {
49
+ // The host's next read of this file reports what is still wrong.
50
+ }
51
+ }
52
+ return restored.sort();
53
+ }
54
+ export function taskDirRestoredNotice(who, names) {
55
+ const list = names.map(n => `${TASKS_DIR_NAME}/${n}`).join(', ');
56
+ return `${who} changed ${list}, which only pi-task writes. Restored.`;
57
+ }
58
+ /** The implementation turn's custody. One slot: one task runs at a time. */
59
+ let held = null;
60
+ async function release(custody) {
61
+ if (held !== custody)
62
+ return [];
63
+ held = null;
64
+ return restoreTaskDir(custody.cwd, custody.snapshot);
65
+ }
66
+ /**
67
+ * Snapshot before the turn can start, replacing any custody still held.
68
+ * `oneShot` means the settle ends it; otherwise the returned release does, and
69
+ * it never releases a custody taken since.
70
+ */
71
+ export async function takeTaskDirCustody(cwd, opts) {
72
+ const custody = { cwd, snapshot: await snapshotTaskDir(cwd), oneShot: opts.oneShot };
73
+ held = custody;
74
+ return () => release(custody);
75
+ }
76
+ export function releaseTaskDirCustody() {
77
+ return held ? release(held) : Promise.resolve([]);
78
+ }
79
+ /** @internal Test seam: is a turn's task dir held? */
80
+ export function taskDirCustodyHeld() {
81
+ return held !== null;
82
+ }
83
+ /**
84
+ * pi awaits these handlers before it wakes a command waiting for idle, so a
85
+ * one-shot restore finishes before the next run writes anything.
86
+ */
87
+ export function registerTaskDirCustody(pi) {
88
+ pi.on('agent_settled', async (_event, ctx) => {
89
+ if (!held?.oneShot)
90
+ return;
91
+ const restored = await releaseTaskDirCustody();
92
+ if (restored.length === 0)
93
+ return;
94
+ notifyRun(ctx, taskDirRestoredNotice('The implementation turn', restored), 'warning');
95
+ });
96
+ // Dropped, not restored: a snapshot this old would revert the next run's writes.
97
+ pi.on('session_shutdown', () => {
98
+ held = null;
99
+ });
100
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.42.1",
3
+ "version": "0.42.2",
4
4
  "description": "Deterministic task planning and spec-orchestration for local models — crash-safe /task pipelines with verify/enforce gates, a real-time remote web view, and web/docs/fetch/worker subagent tools.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",