pi-long-task 0.3.17 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,234 @@
1
+ export type SteeringInputSource = "interactive" | "rpc";
2
+
3
+ export interface SteeringInput {
4
+ text: string;
5
+ source: "interactive" | "rpc" | "extension";
6
+ streamingBehavior?: "steer" | "followUp";
7
+ images?: readonly unknown[];
8
+ }
9
+
10
+ export type SteeringMessageStatus = "queued" | "processing" | "accepted" | "failed";
11
+
12
+ export interface SteeringMessage {
13
+ id: string;
14
+ sequence: number;
15
+ text: string;
16
+ source: SteeringInputSource;
17
+ receivedAt: string;
18
+ status: SteeringMessageStatus;
19
+ error?: string;
20
+ }
21
+
22
+ export type SteeringMessageProcessor = (message: Readonly<SteeringMessage>) => void | Promise<void>;
23
+ export type SteeringMessageObserver = (message: Readonly<SteeringMessage>) => void;
24
+
25
+ /**
26
+ * A run-scoped FIFO for guidance received while a long task is active.
27
+ *
28
+ * A message remains in the queue until its processor resolves successfully. A
29
+ * processor failure pauses the queue so later guidance cannot overtake it;
30
+ * callers may fix the recoverable failure and call retryFailed().
31
+ */
32
+ export class SerializedSteeringQueue {
33
+ private readonly queueId: string;
34
+ private readonly now: () => Date;
35
+ private readonly onChange: SteeringMessageObserver | undefined;
36
+ private readonly messages: SteeringMessage[] = [];
37
+ private processor: SteeringMessageProcessor | undefined;
38
+ private sequence = 0;
39
+ private processing = false;
40
+ private failed = false;
41
+ private closed = false;
42
+ private readonly idleWaiters = new Set<() => void>();
43
+
44
+ constructor(options: { queueId: string; now?: () => Date; onChange?: SteeringMessageObserver }) {
45
+ const queueId = options.queueId.trim();
46
+ if (!queueId) {
47
+ throw new Error("A steering queue requires a non-empty queueId.");
48
+ }
49
+ this.queueId = queueId;
50
+ this.now = options.now ?? (() => new Date());
51
+ this.onChange = options.onChange;
52
+ }
53
+
54
+ enqueue(text: string, source: SteeringInputSource): SteeringMessage {
55
+ if (this.closed) {
56
+ throw new Error("Cannot enqueue guidance after the steering queue is closed.");
57
+ }
58
+ const normalizedText = text.trim();
59
+ if (!normalizedText) {
60
+ throw new Error("Steering guidance must not be empty.");
61
+ }
62
+
63
+ const sequence = ++this.sequence;
64
+ const message: SteeringMessage = {
65
+ id: `${this.queueId}:${sequence}`,
66
+ sequence,
67
+ text: normalizedText,
68
+ source,
69
+ receivedAt: this.now().toISOString(),
70
+ status: "queued",
71
+ };
72
+ this.messages.push(message);
73
+ this.publish(message);
74
+ const queuedMessage = { ...message };
75
+ this.startPump();
76
+ return queuedMessage;
77
+ }
78
+
79
+ /** Attach the single plan-revision consumer. Pending messages start immediately. */
80
+ setProcessor(processor: SteeringMessageProcessor): () => void {
81
+ if (this.processor && this.processor !== processor) {
82
+ throw new Error("A steering queue already has a processor.");
83
+ }
84
+ this.processor = processor;
85
+ this.startPump();
86
+ return () => {
87
+ if (this.processor === processor) {
88
+ this.processor = undefined;
89
+ }
90
+ };
91
+ }
92
+
93
+ /** Retry the failed head message without allowing later messages to overtake it. */
94
+ retryFailed(): void {
95
+ if (!this.failed) {
96
+ return;
97
+ }
98
+ const message = this.messages[0];
99
+ if (message?.status === "failed") {
100
+ message.status = "queued";
101
+ delete message.error;
102
+ this.publish(message);
103
+ }
104
+ this.failed = false;
105
+ this.startPump();
106
+ }
107
+
108
+ pendingMessages(): ReadonlyArray<Readonly<SteeringMessage>> {
109
+ return this.messages.map((message) => ({ ...message }));
110
+ }
111
+
112
+ waitForIdle(): Promise<void> {
113
+ if (!this.processing) {
114
+ return Promise.resolve();
115
+ }
116
+ return new Promise((resolve) => this.idleWaiters.add(resolve));
117
+ }
118
+
119
+ close(): void {
120
+ this.closed = true;
121
+ this.processor = undefined;
122
+ this.resolveIdleWaitersIfIdle();
123
+ }
124
+
125
+ private startPump(): void {
126
+ if (this.processing || this.failed || !this.processor || this.messages.length === 0) {
127
+ return;
128
+ }
129
+ this.processing = true;
130
+ void this.pump();
131
+ }
132
+
133
+ private async pump(): Promise<void> {
134
+ try {
135
+ while (!this.failed && this.processor && this.messages.length > 0) {
136
+ const processor = this.processor;
137
+ const message = this.messages[0];
138
+ message.status = "processing";
139
+ delete message.error;
140
+ this.publish(message);
141
+ try {
142
+ await processor({ ...message });
143
+ } catch (error) {
144
+ message.status = "failed";
145
+ message.error = errorMessage(error);
146
+ this.failed = true;
147
+ this.publish(message);
148
+ break;
149
+ }
150
+
151
+ message.status = "accepted";
152
+ this.publish(message);
153
+ this.messages.shift();
154
+ }
155
+ } finally {
156
+ this.processing = false;
157
+ this.resolveIdleWaitersIfIdle();
158
+ // A message may have arrived between the final loop check and this
159
+ // assignment. Starting again here closes that race without parallelism.
160
+ this.startPump();
161
+ }
162
+ }
163
+
164
+ private publish(message: SteeringMessage): void {
165
+ try {
166
+ this.onChange?.({ ...message });
167
+ } catch {
168
+ // Observability must never affect delivery or ordering.
169
+ }
170
+ }
171
+
172
+ private resolveIdleWaitersIfIdle(): void {
173
+ if (this.processing) {
174
+ return;
175
+ }
176
+ for (const resolve of this.idleWaiters) {
177
+ resolve();
178
+ }
179
+ this.idleWaiters.clear();
180
+ }
181
+ }
182
+
183
+ export type SteeringRouteResult =
184
+ | { routed: true; message: SteeringMessage }
185
+ | {
186
+ routed: false;
187
+ reason: "no_active_run" | "ambiguous_active_runs" | "not_steering" | "control_input" | "images" | "empty";
188
+ };
189
+
190
+ /** Routes input only when exactly one Pi Long Task execution owns steering. */
191
+ export class ActiveLongTaskSteeringRouter {
192
+ private readonly activeQueues = new Map<symbol, SerializedSteeringQueue>();
193
+
194
+ activate(queue: SerializedSteeringQueue): () => void {
195
+ const registration = Symbol("active-long-task-steering");
196
+ this.activeQueues.set(registration, queue);
197
+ return () => {
198
+ this.activeQueues.delete(registration);
199
+ };
200
+ }
201
+
202
+ route(input: SteeringInput): SteeringRouteResult {
203
+ if (input.source === "extension" || input.streamingBehavior !== "steer") {
204
+ return { routed: false, reason: "not_steering" };
205
+ }
206
+ if (input.images && input.images.length > 0) {
207
+ return { routed: false, reason: "images" };
208
+ }
209
+ const text = input.text.trim();
210
+ if (!text) {
211
+ return { routed: false, reason: "empty" };
212
+ }
213
+ if (isControlInput(text)) {
214
+ return { routed: false, reason: "control_input" };
215
+ }
216
+ if (this.activeQueues.size === 0) {
217
+ return { routed: false, reason: "no_active_run" };
218
+ }
219
+ if (this.activeQueues.size > 1) {
220
+ return { routed: false, reason: "ambiguous_active_runs" };
221
+ }
222
+
223
+ const queue = this.activeQueues.values().next().value as SerializedSteeringQueue;
224
+ return { routed: true, message: queue.enqueue(text, input.source) };
225
+ }
226
+ }
227
+
228
+ function isControlInput(text: string): boolean {
229
+ return text.startsWith("/") || text.startsWith("!");
230
+ }
231
+
232
+ function errorMessage(error: unknown): string {
233
+ return error instanceof Error ? error.message : String(error);
234
+ }
@@ -6,6 +6,8 @@ export interface TaskStatusItem {
6
6
  export interface Task {
7
7
  taskId: string;
8
8
  title: string;
9
+ /** Optional identity persisted in a task section as `<!-- pi-long-task-id: value -->`. */
10
+ stableId?: string;
9
11
  section: string;
10
12
  startLine: number;
11
13
  endLine: number;
@@ -27,6 +29,7 @@ const CHECKBOX_RE = /^(\s*-\s+\[)([ xX])(\].*)$/;
27
29
  const GLOBAL_PROGRESS_HEADING_RE = /^##\s+Progress\s*$/i;
28
30
  const FIELD_HEADING_RE = /^\*\*[^*\r\n]+:\*\*\s*$/;
29
31
  const FENCE_LINE_RE = /^\s*(`{3,}|~{3,})/;
32
+ const STABLE_ID_RE = /^\s*<!--\s*pi-long-task-id:\s*([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\s*-->\s*$/i;
30
33
 
31
34
  function progressRegexForTask(taskId: string): RegExp {
32
35
  return new RegExp(`^(\\s*-\\s+\\[)([ xX])(\\]\\s+TODO\\s+${escapeRegExp(taskId)}\\b.*)$`);
@@ -87,6 +90,30 @@ function parseTaskHeadings(lines: string[]): TaskHeading[] {
87
90
  return headings;
88
91
  }
89
92
 
93
+ function findStableId(lines: string[], startIdx: number, endIdx: number): string | undefined {
94
+ let fence: string | undefined;
95
+ for (let idx = startIdx; idx < endIdx; idx += 1) {
96
+ const stripped = stripLineBreaks(lines[idx]);
97
+ const fenceMatch = FENCE_LINE_RE.exec(stripped);
98
+ if (fenceMatch) {
99
+ const marker = fenceMatch[1];
100
+ if (!fence) {
101
+ fence = marker;
102
+ } else if (marker[0] === fence[0] && marker.length >= fence.length) {
103
+ fence = undefined;
104
+ }
105
+ continue;
106
+ }
107
+ if (!fence) {
108
+ const match = STABLE_ID_RE.exec(stripped);
109
+ if (match) {
110
+ return match[1];
111
+ }
112
+ }
113
+ }
114
+ return undefined;
115
+ }
116
+
90
117
  function findProgressDone(lines: string[], taskId: string): boolean | undefined {
91
118
  const regex = progressRegexForTask(taskId);
92
119
  const progressStart = lines.findIndex((line) => GLOBAL_PROGRESS_HEADING_RE.test(stripLineBreaks(line).trim()));
@@ -146,9 +173,10 @@ function findStatusItems(lines: string[], startIdx: number, endIdx: number): Tas
146
173
  return items;
147
174
  }
148
175
 
149
- function markStatusBlockDone(lines: string[], startIdx: number, endIdx: number): void {
176
+ function setStatusBlockDone(lines: string[], startIdx: number, endIdx: number, done: boolean): void {
150
177
  let inStatus = false;
151
178
  let seenCheckbox = false;
179
+ const marker = done ? "x" : " ";
152
180
 
153
181
  for (let idx = startIdx; idx < endIdx; idx += 1) {
154
182
  const stripped = lines[idx].trim();
@@ -166,8 +194,8 @@ function markStatusBlockDone(lines: string[], startIdx: number, endIdx: number):
166
194
  const checkbox = CHECKBOX_RE.exec(raw);
167
195
  if (checkbox) {
168
196
  seenCheckbox = true;
169
- if (checkbox[2].toLowerCase() !== "x") {
170
- lines[idx] = `${checkbox[1]}x${checkbox[3]}${newline}`;
197
+ if ((checkbox[2].toLowerCase() === "x") !== done) {
198
+ lines[idx] = `${checkbox[1]}${marker}${checkbox[3]}${newline}`;
171
199
  }
172
200
  continue;
173
201
  }
@@ -201,6 +229,7 @@ export function parseTasks(markdown: string): Task[] {
201
229
  const task: Task = {
202
230
  taskId: heading.taskId,
203
231
  title: heading.title,
232
+ stableId: findStableId(lines, heading.startIdx, endIdx),
204
233
  section,
205
234
  startLine: heading.startIdx + 1,
206
235
  endLine: endIdx,
@@ -219,16 +248,17 @@ export function incompleteTasks(markdown: string): Task[] {
219
248
  return parseTasks(markdown).filter((task) => !task.done);
220
249
  }
221
250
 
222
- export function markTaskDone(markdown: string, taskId: string): string {
251
+ function setTaskDone(markdown: string, taskId: string, done: boolean): string {
223
252
  const lines = splitLinesKeepEnds(markdown);
224
253
  const progressRegex = progressRegexForTask(taskId);
254
+ const marker = done ? "x" : " ";
225
255
 
226
256
  lines.forEach((line, idx) => {
227
257
  const raw = stripLineBreaks(line);
228
258
  const newline = line.endsWith("\n") ? "\n" : "";
229
259
  const match = progressRegex.exec(raw);
230
- if (match && match[2].toLowerCase() !== "x") {
231
- lines[idx] = `${match[1]}x${match[3]}${newline}`;
260
+ if (match && (match[2].toLowerCase() === "x") !== done) {
261
+ lines[idx] = `${match[1]}${marker}${match[3]}${newline}`;
232
262
  }
233
263
  });
234
264
 
@@ -236,12 +266,21 @@ export function markTaskDone(markdown: string, taskId: string): string {
236
266
  const headingPos = headings.findIndex((heading) => heading.taskId === taskId);
237
267
  if (headingPos >= 0) {
238
268
  const endIdx = headingPos + 1 < headings.length ? headings[headingPos + 1].startIdx : lines.length;
239
- markStatusBlockDone(lines, headings[headingPos].startIdx, endIdx);
269
+ setStatusBlockDone(lines, headings[headingPos].startIdx, endIdx, done);
240
270
  }
241
271
 
242
272
  return lines.join("");
243
273
  }
244
274
 
275
+ export function markTaskDone(markdown: string, taskId: string): string {
276
+ return setTaskDone(markdown, taskId, true);
277
+ }
278
+
279
+ /** Clears planner-supplied completion for work whose coordinator-owned state is not complete. */
280
+ export function markTaskPending(markdown: string, taskId: string): string {
281
+ return setTaskDone(markdown, taskId, false);
282
+ }
283
+
245
284
  export function todoGlobalInstructions(markdown: string, limit = 6000): string {
246
285
  const selected: string[] = [];
247
286
 
@@ -3,8 +3,12 @@ export interface ParsedWorkerRuntimeConfig {
3
3
  maxAttemptsPerTask?: number;
4
4
  taskTimeoutMs?: number;
5
5
  maxBashTimeoutMs?: number;
6
+ workerSessionReuseEnabled?: boolean;
7
+ workerSessionReuseContextThresholdPercent?: number;
6
8
  }
7
9
 
10
+ type MutableWorkerRuntimeConfig = ParsedWorkerRuntimeConfig & { provider?: string; model?: string };
11
+
8
12
  const MODEL_TOKEN_RE = /[A-Za-z0-9][A-Za-z0-9._~:+/@-]*/;
9
13
  const STOP_WORDS = new Set([
10
14
  "and",
@@ -28,7 +32,7 @@ const STOP_WORDS = new Set([
28
32
  ]);
29
33
 
30
34
  export function parseWorkerRuntimeConfig(text: string): ParsedWorkerRuntimeConfig {
31
- const state: ParsedWorkerRuntimeConfig & { provider?: string; model?: string } = {};
35
+ const state: MutableWorkerRuntimeConfig = {};
32
36
 
33
37
  parseLineDirectives(text, state);
34
38
  parseNaturalLanguageDirectives(text, state);
@@ -39,13 +43,16 @@ export function parseWorkerRuntimeConfig(text: string): ParsedWorkerRuntimeConfi
39
43
  ...(state.maxAttemptsPerTask !== undefined ? { maxAttemptsPerTask: state.maxAttemptsPerTask } : {}),
40
44
  ...(state.taskTimeoutMs !== undefined ? { taskTimeoutMs: state.taskTimeoutMs } : {}),
41
45
  ...(state.maxBashTimeoutMs !== undefined ? { maxBashTimeoutMs: state.maxBashTimeoutMs } : {}),
46
+ ...(state.workerSessionReuseEnabled !== undefined
47
+ ? { workerSessionReuseEnabled: state.workerSessionReuseEnabled }
48
+ : {}),
49
+ ...(state.workerSessionReuseContextThresholdPercent !== undefined
50
+ ? { workerSessionReuseContextThresholdPercent: state.workerSessionReuseContextThresholdPercent }
51
+ : {}),
42
52
  };
43
53
  }
44
54
 
45
- function parseLineDirectives(
46
- text: string,
47
- state: ParsedWorkerRuntimeConfig & { provider?: string; model?: string },
48
- ): void {
55
+ function parseLineDirectives(text: string, state: MutableWorkerRuntimeConfig): void {
49
56
  for (const rawLine of text.split(/\r?\n/)) {
50
57
  const line = rawLine.replace(/^\s{0,3}>+\s?/, "").trim();
51
58
  const match = line.match(
@@ -64,10 +71,7 @@ function parseLineDirectives(
64
71
  }
65
72
  }
66
73
 
67
- function parseNaturalLanguageDirectives(
68
- text: string,
69
- state: ParsedWorkerRuntimeConfig & { provider?: string; model?: string },
70
- ): void {
74
+ function parseNaturalLanguageDirectives(text: string, state: MutableWorkerRuntimeConfig): void {
71
75
  captureTokens(
72
76
  text,
73
77
  /\bworker\s+(?:model|provider\/model)\s*(?:is|=|:|to|as)?\s*[`'"]?([A-Za-z0-9][A-Za-z0-9._~:+/@-]*)/gi,
@@ -139,13 +143,38 @@ function parseNaturalLanguageDirectives(
139
143
  state.maxBashTimeoutMs = value;
140
144
  },
141
145
  );
146
+
147
+ for (const match of text.matchAll(
148
+ /\b(?:worker\s+)?session\s+reuse(?:\s+is|\s*=|\s*:|\s+to)?\s+(enabled|disabled|on|off|true|false)\b/gi,
149
+ )) {
150
+ const enabled = booleanSetting(match[1] ?? "");
151
+ if (enabled !== undefined) state.workerSessionReuseEnabled = enabled;
152
+ }
153
+ for (const match of text.matchAll(
154
+ /\b(?:worker\s+)?(?:session\s+)?reuse\s+context(?:\s+usage)?\s+threshold\s*(?:is|=|:|to|of)?\s*(\d+(?:\.\d+)?)\s*%/gi,
155
+ )) {
156
+ const threshold = percentageFromText(match[1] ?? "");
157
+ if (threshold !== undefined) state.workerSessionReuseContextThresholdPercent = threshold;
158
+ }
142
159
  }
143
160
 
144
- function applyDirective(
145
- key: string,
146
- value: string,
147
- state: ParsedWorkerRuntimeConfig & { provider?: string; model?: string },
148
- ): void {
161
+ function applyDirective(key: string, value: string, state: MutableWorkerRuntimeConfig): void {
162
+ if (/\breuse\b/.test(key) && /\b(?:threshold|context)\b/.test(key)) {
163
+ const threshold = percentageFromText(value);
164
+ if (threshold !== undefined) {
165
+ state.workerSessionReuseContextThresholdPercent = threshold;
166
+ }
167
+ return;
168
+ }
169
+
170
+ if (/\breuse\b/.test(key)) {
171
+ const enabled = booleanSetting(value);
172
+ if (enabled !== undefined) {
173
+ state.workerSessionReuseEnabled = enabled;
174
+ }
175
+ return;
176
+ }
177
+
149
178
  if (/\bprovider\b/.test(key)) {
150
179
  const token = modelToken(value);
151
180
  if (token) {
@@ -250,6 +279,26 @@ function positiveIntegerFromText(value: string): number | undefined {
250
279
  return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
251
280
  }
252
281
 
282
+ function percentageFromText(value: string): number | undefined {
283
+ const match = /\d+(?:\.\d+)?/.exec(value);
284
+ if (!match) {
285
+ return undefined;
286
+ }
287
+ const parsed = Number.parseFloat(match[0]);
288
+ return Number.isFinite(parsed) && parsed > 0 && parsed <= 100 ? parsed : undefined;
289
+ }
290
+
291
+ function booleanSetting(value: string): boolean | undefined {
292
+ const normalized = trimDirectiveValue(value).toLowerCase().split(/\s+/)[0];
293
+ if (normalized === "enabled" || normalized === "on" || normalized === "true" || normalized === "yes") {
294
+ return true;
295
+ }
296
+ if (normalized === "disabled" || normalized === "off" || normalized === "false" || normalized === "no") {
297
+ return false;
298
+ }
299
+ return undefined;
300
+ }
301
+
253
302
  function durationMsFromText(value: string, options: { allowBareSeconds: boolean }): number | undefined {
254
303
  const match = /(\d+(?:\.\d+)?)\s*(milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h)?\b/i.exec(
255
304
  value,