tau-coding-agent 0.1.5 → 0.2.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.
Files changed (86) hide show
  1. package/README.md +15 -12
  2. package/extensions/answer.ts +129 -73
  3. package/extensions/branch-term/README.md +7 -0
  4. package/extensions/{branch-term.ts → branch-term/index.ts} +113 -104
  5. package/extensions/btw.ts +90 -4
  6. package/extensions/caffeinate/README.md +5 -0
  7. package/extensions/caffeinate/index.ts +144 -0
  8. package/extensions/fast.ts +292 -0
  9. package/extensions/ghostty.ts +214 -211
  10. package/extensions/git-diff-stats.ts +124 -84
  11. package/extensions/git-pr-status.ts +274 -208
  12. package/extensions/insights.ts +138 -183
  13. package/extensions/loop.ts +150 -135
  14. package/extensions/memory.ts +172 -97
  15. package/extensions/notify.ts +14 -26
  16. package/extensions/openai-verbosity.ts +108 -42
  17. package/extensions/review/fix.ts +15 -6
  18. package/extensions/review/git.ts +93 -103
  19. package/extensions/review/index.ts +60 -62
  20. package/extensions/review/interrupt.ts +117 -0
  21. package/extensions/review/message-queue.ts +45 -12
  22. package/extensions/review/models.ts +79 -3
  23. package/extensions/review/prompts.ts +43 -40
  24. package/extensions/review/review.ts +161 -166
  25. package/extensions/review/runner.ts +139 -147
  26. package/extensions/review/runtime.ts +173 -120
  27. package/extensions/review/submit-review-tool.ts +1 -5
  28. package/extensions/review/submit-triage-tool.ts +1 -0
  29. package/extensions/review/triage.ts +41 -44
  30. package/extensions/sandbox/bash.ts +775 -0
  31. package/extensions/sandbox/command.ts +605 -0
  32. package/extensions/sandbox/config.ts +764 -0
  33. package/extensions/sandbox/index.ts +138 -2850
  34. package/extensions/sandbox/macos-sandbox-shell.mjs +202 -0
  35. package/extensions/sandbox/permissions/dialog.ts +118 -0
  36. package/extensions/sandbox/permissions/filesystem.ts +559 -0
  37. package/extensions/sandbox/permissions/mach-lookup.ts +186 -0
  38. package/extensions/sandbox/permissions/network.ts +164 -0
  39. package/extensions/sandbox/permissions/unsandboxed.ts +270 -0
  40. package/extensions/sandbox/runtime.ts +616 -0
  41. package/extensions/stash.ts +28 -15
  42. package/extensions/subagent/README.md +73 -0
  43. package/extensions/subagent/index.ts +822 -0
  44. package/extensions/subagent/interrupt.ts +117 -0
  45. package/extensions/subagent/permissions.ts +101 -0
  46. package/extensions/subagent/rpc.ts +177 -0
  47. package/extensions/tool-display-mode.ts +267 -64
  48. package/extensions/usage/anthropic.ts +5 -6
  49. package/extensions/usage/github-copilot.ts +30 -9
  50. package/extensions/usage/index.ts +250 -357
  51. package/extensions/usage/openai-codex.ts +2 -2
  52. package/extensions/usage/openrouter.ts +10 -2
  53. package/extensions/websearch/README.md +12 -47
  54. package/extensions/websearch/config.ts +5 -2
  55. package/extensions/websearch/index.ts +94 -109
  56. package/extensions/websearch/output.ts +51 -0
  57. package/extensions/websearch/providers/anthropic.pi.ts +27 -44
  58. package/extensions/websearch/providers/gemini.browser.ts +68 -77
  59. package/extensions/websearch/providers/gemini.pi.ts +17 -17
  60. package/extensions/websearch/providers/openai-codex.pi.ts +145 -7
  61. package/extensions/websearch/providers/pi-model.shared.ts +44 -32
  62. package/extensions/websearch/providers/shared.ts +87 -2
  63. package/extensions/websearch/types.ts +0 -3
  64. package/extensions/worktree.ts +132 -172
  65. package/package.json +10 -5
  66. package/skills/browser-tools/SKILL.md +29 -234
  67. package/skills/browser-tools/references/cookies.md +36 -0
  68. package/skills/browser-tools/references/interaction.md +90 -0
  69. package/skills/browser-tools/references/logging.md +34 -0
  70. package/skills/git-clean-history/SKILL.md +6 -6
  71. package/skills/git-commit/SKILL.md +5 -3
  72. package/skills/github-pull-request/SKILL.md +60 -0
  73. package/skills/github-pull-request/references/create.md +40 -0
  74. package/skills/github-pull-request/references/stewardship.md +60 -0
  75. package/skills/oracle/SKILL.md +4 -4
  76. package/skills/oracle/scripts/oracle +64 -51
  77. package/skills/sentry/SKILL.md +15 -185
  78. package/skills/sentry/references/events.md +79 -0
  79. package/skills/sentry/references/issues.md +62 -0
  80. package/skills/sentry/references/logs.md +46 -0
  81. package/skills/update-changelog/SKILL.md +27 -121
  82. package/skills/web-design/SKILL.md +16 -105
  83. package/themes/tau-dark.json +4 -0
  84. package/extensions/openai-fast.ts +0 -229
  85. package/extensions/websearch/providers/openai-codex.browser.ts +0 -77
  86. package/extensions/websearch/providers/openai-codex.shared.ts +0 -123
@@ -1,5 +1,10 @@
1
- import { complete } from "@earendil-works/pi-ai/compat";
2
- import { StringEnum, type Api, type Model, type UserMessage } from "@earendil-works/pi-ai";
1
+ import {
2
+ StringEnum,
3
+ type Api,
4
+ type Model,
5
+ type Usage,
6
+ type UserMessage,
7
+ } from "@earendil-works/pi-ai";
3
8
  import {
4
9
  defineTool,
5
10
  withFileMutationQueue,
@@ -8,6 +13,7 @@ import {
8
13
  type ExtensionContext,
9
14
  } from "@earendil-works/pi-coding-agent";
10
15
  import { Type } from "typebox";
16
+ import { createHash, randomUUID } from "node:crypto";
11
17
  import fs from "node:fs/promises";
12
18
  import path from "node:path";
13
19
 
@@ -65,6 +71,9 @@ Schema:
65
71
  "focus": "...",
66
72
  "pending": "..."
67
73
  },
74
+ "pending_dispositions": [
75
+ { "id": "pending-1", "status": "retain" }
76
+ ],
68
77
  "summary": "..."
69
78
  }
70
79
 
@@ -78,9 +87,12 @@ Rules:
78
87
  - focus: the current objective and immediate next steps.
79
88
  - pending: unresolved follow-ups, open questions, blocked work.
80
89
  - Use explicit invalidates/supersedes links when resolving contradictions.
81
- - Preserve unresolved pending items. Do not clear pending unless the summary explicitly says they were resolved or abandoned.
90
+ - The <pending-items> JSON assigns snapshot-local identifiers to every nonblank line of existing pending.md, including headings and continuation lines. This conservative unit preserves arbitrary existing Markdown without guessing item boundaries.
91
+ - Return pending_dispositions with exactly one {id, status} per provided identifier, no duplicates or unknown identifiers. Return [] when no identifiers were provided.
92
+ - status must be "retain", "complete", or "abandon". Use complete or abandon only when the provided evidence establishes that work is finished or deliberately abandoned; otherwise retain. Summary wording never authorizes deletion.
93
+ - Every retained line must remain verbatim as a separate line in blocks.pending, including repeated occurrences. Do not replace retained work with a paraphrase or an identifier. For multiline work, retain every constituent line unless that work is complete or abandoned. You may reorder retained lines and add new pending work.
82
94
  - Keep every block short, concrete, and high-signal.
83
- - Self-check that JSON.parse(output) succeeds before responding.`;
95
+ - Before returning, check that the output is valid JSON and matches the required structure.`;
84
96
 
85
97
  // --- Types and errors ---
86
98
 
@@ -106,20 +118,27 @@ type PendingCompaction = {
106
118
  };
107
119
 
108
120
  type MemoryState = {
109
- // Newest timestamp appended to log.md.
121
+ // Timestamp of the last entry appended to log.md.
110
122
  lastLogAt: string | null;
111
123
  // Timestamp of the most recent successful dream run.
112
124
  lastDreamAt: string | null;
113
- // Next dream replays log.md entries newer than this timestamp.
125
+ // Timestamp provenance only; replay follows the append cursor, not wall time.
114
126
  lastDreamedLogAt: string | null;
127
+ lastDreamedLogCursor: LogCursor | null;
115
128
  // session_compact summaries waiting to be folded into core.
116
129
  pendingCompactions: PendingCompaction[];
117
130
  };
118
131
 
132
+ type LogCursor = {
133
+ bytes: number;
134
+ sha256: string;
135
+ };
136
+
119
137
  type MemoryStateFile = {
120
138
  last_log_at: string | null;
121
139
  last_dream_at: string | null;
122
140
  last_dreamed_log_at: string | null;
141
+ last_dreamed_log_cursor: LogCursor | null;
123
142
  pending_compactions: PendingCompaction[];
124
143
  };
125
144
 
@@ -152,10 +171,17 @@ type DreamReplay = {
152
171
  totalLines: number;
153
172
  totalChars: number;
154
173
  undreamedEntries: LogEntry[];
174
+ logCursor: LogCursor;
175
+ previousLogCursor: LogCursor | null;
155
176
  pendingCompactions: PendingCompaction[];
156
177
  researchFiles: string[];
157
178
  };
158
179
 
180
+ type PendingItem = {
181
+ id: string;
182
+ text: string;
183
+ };
184
+
159
185
  type DreamOutput = {
160
186
  blocks: CoreBlocks;
161
187
  summary: string;
@@ -171,6 +197,11 @@ type DreamResult = {
171
197
  summaryPath: string | null;
172
198
  };
173
199
 
200
+ type DreamExecution = {
201
+ result: DreamResult;
202
+ usage?: Usage;
203
+ };
204
+
174
205
  type AutoDreamStatus = {
175
206
  coreLines: number;
176
207
  coreChars: number;
@@ -187,8 +218,6 @@ type AutoDreamState = {
187
218
 
188
219
  type ModelSelection = {
189
220
  model: Model<Api>;
190
- apiKey?: string;
191
- headers?: Record<string, string>;
192
221
  };
193
222
 
194
223
  class MemoryReadmeMissingError extends Error {
@@ -204,12 +233,17 @@ async function toolDream(
204
233
  cwd: string,
205
234
  ctx: ExtensionContext,
206
235
  reason?: string,
207
- ): Promise<{ content: Array<{ type: "text"; text: string }>; details: DreamResult }> {
236
+ ): Promise<{
237
+ content: Array<{ type: "text"; text: string }>;
238
+ details: DreamResult;
239
+ usage?: Usage;
240
+ }> {
208
241
  try {
209
- const result = await runMemoryDream(cwd, ctx, reason);
242
+ const execution = await runMemoryDream(cwd, ctx, reason);
210
243
  return {
211
- content: [{ type: "text", text: result.summary }],
212
- details: result,
244
+ content: [{ type: "text", text: execution.result.summary }],
245
+ details: execution.result,
246
+ usage: execution.usage,
213
247
  };
214
248
  } catch (error) {
215
249
  if (error instanceof MemoryReadmeMissingError) {
@@ -272,12 +306,6 @@ async function maybeScheduleAutoDream(
272
306
  return true;
273
307
  }
274
308
 
275
- const noUndreamedLogs =
276
- stateFile.lastLogAt !== null && stateFile.lastLogAt === stateFile.lastDreamedLogAt;
277
- if (noUndreamedLogs) {
278
- return shouldAutoDream(quickStatus, trigger) ? startAutoDream(quickStatus) : true;
279
- }
280
-
281
309
  const status = await loadAutoDreamStatus(cwd, core, stateFile);
282
310
  return shouldAutoDream(status, trigger) ? startAutoDream(status) : true;
283
311
  }
@@ -356,20 +384,21 @@ function getDreamReplaySignature(replay: DreamReplay): string {
356
384
  readme: replay.readme,
357
385
  blocks: replay.blocks,
358
386
  undreamedEntries: replay.undreamedEntries,
387
+ logCursor: replay.logCursor,
388
+ previousLogCursor: replay.previousLogCursor,
359
389
  pendingCompactions: replay.pendingCompactions,
360
390
  researchFiles: replay.researchFiles,
361
391
  });
362
392
  }
363
393
 
364
- // Dream reads current core, log.md entries newer than lastDreamedLogAt,
365
- // pending compactions, and the memory README rules.
366
- // Dream writes rewritten core blocks, a dream summary in compactions/,
367
- // and state.json with lastDreamAt, lastDreamedLogAt, and an empty pendingCompactions queue.
394
+ // Dream reads current core, logs after the persisted append cursor,
395
+ // pending compactions, and the memory README rules, then commits only if unchanged.
396
+ // A successful dream advances the cursor and clears pendingCompactions.
368
397
  async function runMemoryDream(
369
398
  cwd: string,
370
399
  ctx: ExtensionContext,
371
400
  reason?: string,
372
- ): Promise<DreamResult> {
401
+ ): Promise<DreamExecution> {
373
402
  const paths = getMemoryPaths(cwd);
374
403
  const lockPaths = [
375
404
  paths.readmeFile,
@@ -394,28 +423,26 @@ async function runMemoryDream(
394
423
  const summary = "Memory dream: nothing to consolidate.";
395
424
  notify(ctx, summary, "info");
396
425
  return {
397
- summary,
398
- updatedBlocks: [],
399
- consumedLogs: 0,
400
- consumedCompactions: 0,
401
- lastDreamAt: snapshot.state?.lastDreamAt ?? nowIso(),
402
- lastDreamedLogAt: snapshot.state?.lastDreamedLogAt ?? null,
403
- summaryPath: null,
426
+ result: {
427
+ summary,
428
+ updatedBlocks: [],
429
+ consumedLogs: 0,
430
+ consumedCompactions: 0,
431
+ lastDreamAt: snapshot.state?.lastDreamAt ?? nowIso(),
432
+ lastDreamedLogAt: snapshot.state?.lastDreamedLogAt ?? null,
433
+ summaryPath: null,
434
+ },
404
435
  };
405
436
  }
406
437
 
407
438
  const selection = await selectDreamModel(ctx);
408
- const response = await complete(
439
+ const response = await ctx.modelRegistry.complete(
409
440
  selection.model,
410
441
  {
411
442
  systemPrompt: MEMORY_DREAM_SYSTEM_PROMPT,
412
443
  messages: [buildDreamUserMessage(snapshot.replay, reason)],
413
444
  },
414
- {
415
- apiKey: selection.apiKey,
416
- headers: selection.headers,
417
- signal: ctx.signal,
418
- },
445
+ { signal: ctx.signal },
419
446
  );
420
447
 
421
448
  if (response.stopReason === "aborted") {
@@ -436,19 +463,8 @@ async function runMemoryDream(
436
463
  throw new Error("Memory dream returned no text.");
437
464
  }
438
465
 
439
- const abstraction = parseDreamOutput(rawText);
466
+ const abstraction = parseDreamOutput(rawText, getPendingItems(snapshot.replay.blocks.pending));
440
467
  const finalBlocks = abstraction.blocks;
441
- if (
442
- wouldWipePendingWithoutResolution(
443
- snapshot.replay.blocks.pending,
444
- finalBlocks.pending,
445
- abstraction.summary,
446
- )
447
- ) {
448
- throw new Error(
449
- "Memory dream proposed clearing pending.md without saying it was resolved or abandoned.",
450
- );
451
- }
452
468
 
453
469
  const finalCoreLines = getCoreLineCount(finalBlocks);
454
470
  if (finalCoreLines > CORE_LINE_CAP) {
@@ -464,7 +480,7 @@ async function runMemoryDream(
464
480
  );
465
481
  }
466
482
 
467
- return withMemoryMutationQueue(lockPaths, async () => {
483
+ const result = await withMemoryMutationQueue(lockPaths, async () => {
468
484
  ensureMemoryInitialized(await pathExists(paths.memoryRoot));
469
485
 
470
486
  const currentReplay = await collectDreamReplay(cwd);
@@ -482,6 +498,7 @@ async function runMemoryDream(
482
498
  ...previousState,
483
499
  lastDreamAt: dreamTimestamp,
484
500
  lastDreamedLogAt,
501
+ lastDreamedLogCursor: snapshot.replay.logCursor,
485
502
  pendingCompactions: [],
486
503
  });
487
504
 
@@ -512,6 +529,8 @@ async function runMemoryDream(
512
529
  summaryPath,
513
530
  };
514
531
  });
532
+
533
+ return { result, usage: response.usage };
515
534
  }
516
535
 
517
536
  async function loadMemoryPrompt(cwd: string): Promise<string | undefined> {
@@ -567,7 +586,7 @@ async function loadMemoryStatus(cwd: string): Promise<MemoryStatus> {
567
586
  lastLogAt: entries.at(-1)?.timestamp ?? state.lastLogAt,
568
587
  lastDreamAt: state.lastDreamAt,
569
588
  lastDreamedLogAt: state.lastDreamedLogAt,
570
- undreamedLogs: countUndreamedLogs(entries, state.lastDreamedLogAt),
589
+ undreamedLogs: selectUndreamedLogEntries(logText ?? "", state).length,
571
590
  pendingCompactions: state.pendingCompactions.length,
572
591
  researchFiles: researchFiles.length,
573
592
  hasLog,
@@ -580,8 +599,7 @@ async function loadAutoDreamStatus(
580
599
  state: MemoryState,
581
600
  ): Promise<AutoDreamStatus> {
582
601
  const logText = await readTextIfExists(getMemoryPaths(cwd).logFile);
583
- const entries = parseMemoryLog(logText ?? "");
584
- const undreamedEntries = selectUndreamedLogEntries(entries, state.lastDreamedLogAt);
602
+ const undreamedEntries = selectUndreamedLogEntries(logText ?? "", state);
585
603
 
586
604
  return {
587
605
  coreLines: core.totalLines,
@@ -644,14 +662,14 @@ async function updateCoreBlockUnsafe(
644
662
  const totalLines = getCoreLineCount(nextBlocks);
645
663
  if (totalLines > CORE_LINE_CAP) {
646
664
  throw new Error(
647
- `Core block update would exceed the ${CORE_LINE_CAP}-line cap (${totalLines}). Run memory_dream or remove content first.`,
665
+ `Core block update would exceed the ${CORE_LINE_CAP}-line cap (${totalLines}). Run memory_dream to consolidate core memory.`,
648
666
  );
649
667
  }
650
668
 
651
669
  const totalChars = getCoreCharCount(nextBlocks);
652
670
  if (totalChars > CORE_CHAR_CAP) {
653
671
  throw new Error(
654
- `Core block update would exceed the ${CORE_CHAR_CAP}-character cap (${totalChars}). Run memory_dream or remove content first.`,
672
+ `Core block update would exceed the ${CORE_CHAR_CAP}-character cap (${totalChars}). Run memory_dream to consolidate core memory.`,
655
673
  );
656
674
  }
657
675
 
@@ -706,8 +724,7 @@ async function collectDreamReplay(cwd: string): Promise<DreamReplay> {
706
724
  readTextIfExists(paths.logFile),
707
725
  listResearchFiles(cwd),
708
726
  ]);
709
- const entries = parseMemoryLog(logText ?? "");
710
- const undreamedEntries = selectUndreamedLogEntries(entries, state.lastDreamedLogAt);
727
+ const undreamedEntries = selectUndreamedLogEntries(logText ?? "", state);
711
728
 
712
729
  return {
713
730
  readme,
@@ -715,6 +732,8 @@ async function collectDreamReplay(cwd: string): Promise<DreamReplay> {
715
732
  totalLines: core.totalLines,
716
733
  totalChars: core.totalChars,
717
734
  undreamedEntries,
735
+ logCursor: getLogCursor(Buffer.from(logText ?? "", "utf8")),
736
+ previousLogCursor: state.lastDreamedLogCursor,
718
737
  pendingCompactions: state.pendingCompactions,
719
738
  researchFiles,
720
739
  };
@@ -773,6 +792,7 @@ async function readStateUnsafe(cwd: string): Promise<MemoryState> {
773
792
  lastDreamAt: typeof parsed.last_dream_at === "string" ? parsed.last_dream_at : null,
774
793
  lastDreamedLogAt:
775
794
  typeof parsed.last_dreamed_log_at === "string" ? parsed.last_dreamed_log_at : null,
795
+ lastDreamedLogCursor: parseLogCursor(parsed.last_dreamed_log_cursor),
776
796
  pendingCompactions: Array.isArray(parsed.pending_compactions)
777
797
  ? parsed.pending_compactions
778
798
  .map((entry) => normalizePendingCompaction(entry))
@@ -783,7 +803,7 @@ async function readStateUnsafe(cwd: string): Promise<MemoryState> {
783
803
  } catch (error) {
784
804
  const message = error instanceof Error ? error.message : String(error);
785
805
  throw new Error(
786
- `${path.relative(cwd, statePath)} is invalid JSON. Repair or restore it before using repo memory. ${message}`,
806
+ `${path.relative(cwd, statePath)} is invalid. Repair or restore it before using repo memory. ${message}`,
787
807
  );
788
808
  }
789
809
  }
@@ -794,6 +814,7 @@ async function writeStateUnsafe(cwd: string, state: MemoryState): Promise<void>
794
814
  last_log_at: state.lastLogAt,
795
815
  last_dream_at: state.lastDreamAt,
796
816
  last_dreamed_log_at: state.lastDreamedLogAt,
817
+ last_dreamed_log_cursor: state.lastDreamedLogCursor,
797
818
  pending_compactions: state.pendingCompactions,
798
819
  };
799
820
  const payload = `${JSON.stringify(stateFile, null, 2)}\n`;
@@ -838,7 +859,7 @@ async function writeDreamSummary(
838
859
  const paths = getMemoryPaths(cwd);
839
860
  const filePath = path.join(
840
861
  paths.compactionsDir,
841
- `${formatTimestampForFilename(summary.timestamp)}-dream.md`,
862
+ `${formatTimestampForFilename(summary.timestamp)}-${randomUUID()}-dream.md`,
842
863
  );
843
864
  await ensureDir(paths.compactionsDir);
844
865
  await fs.writeFile(filePath, buildDreamSummaryContent(summary), "utf8");
@@ -928,6 +949,10 @@ function buildDreamUserMessage(replay: DreamReplay, reason?: string): UserMessag
928
949
  replay.blocks.pending.trimEnd() || "(empty)",
929
950
  "</core>",
930
951
  "",
952
+ "<pending-items>",
953
+ JSON.stringify(getPendingItems(replay.blocks.pending), null, 2),
954
+ "</pending-items>",
955
+ "",
931
956
  "<undreamed-log>",
932
957
  formatLogEntriesForPrompt(replay.undreamedEntries) || "(none)",
933
958
  "</undreamed-log>",
@@ -958,14 +983,10 @@ async function selectDreamModel(ctx: ExtensionContext): Promise<ModelSelection>
958
983
  throw new Error("error" in auth ? auth.error : "Memory dream is missing model auth.");
959
984
  }
960
985
 
961
- return {
962
- model: ctx.model,
963
- apiKey: auth.apiKey,
964
- headers: auth.headers,
965
- };
986
+ return { model: ctx.model };
966
987
  }
967
988
 
968
- function parseDreamOutput(rawText: string): DreamOutput {
989
+ function parseDreamOutput(rawText: string, pendingItems: PendingItem[]): DreamOutput {
969
990
  const parsed = parsePossiblyWrappedJson(rawText);
970
991
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
971
992
  throw new Error("Memory dream output must be a JSON object.");
@@ -993,6 +1014,8 @@ function parseDreamOutput(rawText: string): DreamOutput {
993
1014
  blocks[name] = normalizeMarkdownBlock(value);
994
1015
  }
995
1016
 
1017
+ validatePendingDispositions(record.pending_dispositions, pendingItems, blocks.pending);
1018
+
996
1019
  const summary =
997
1020
  typeof record.summary === "string" && record.summary.trim()
998
1021
  ? normalizeInlineText(record.summary)
@@ -1001,28 +1024,55 @@ function parseDreamOutput(rawText: string): DreamOutput {
1001
1024
  return { blocks, summary };
1002
1025
  }
1003
1026
 
1004
- // Reject dream proposals that clear pending.md unless the summary says the work
1005
- // was resolved or abandoned.
1006
- function wouldWipePendingWithoutResolution(
1007
- previousPending: string,
1027
+ // Snapshot-local line IDs avoid changing existing free-form pending.md files.
1028
+ // Treating every nonblank line as a unit also protects prose and continuation lines.
1029
+ function getPendingItems(pending: string): PendingItem[] {
1030
+ return normalizeMarkdownBlock(pending)
1031
+ .split("\n")
1032
+ .map((text, index) => ({ id: `pending-${index + 1}`, text }))
1033
+ .filter((item) => item.text.trim().length > 0);
1034
+ }
1035
+
1036
+ function validatePendingDispositions(
1037
+ input: unknown,
1038
+ items: PendingItem[],
1008
1039
  nextPending: string,
1009
- summary: string,
1010
- ): boolean {
1011
- if (!previousPending.trim()) {
1012
- return false;
1040
+ ): void {
1041
+ if (!Array.isArray(input)) {
1042
+ throw new Error("Memory dream must include a pending_dispositions array.");
1013
1043
  }
1014
-
1015
- if (nextPending.trim()) {
1016
- return false;
1044
+ const byId = new Map(items.map((item) => [item.id, item.text]));
1045
+ const seen = new Set<string>();
1046
+ const nextLines = nextPending.split("\n");
1047
+ for (const value of input) {
1048
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
1049
+ throw new Error("Memory dream has an invalid pending disposition.");
1050
+ }
1051
+ const disposition = value as Record<string, unknown>;
1052
+ const { id, status } = disposition;
1053
+ if (typeof id !== "string" || !byId.has(id) || seen.has(id)) {
1054
+ throw new Error("Memory dream has a duplicate or unknown pending identifier.");
1055
+ }
1056
+ if (
1057
+ Object.keys(disposition).some((key) => key !== "id" && key !== "status") ||
1058
+ (status !== "retain" && status !== "complete" && status !== "abandon")
1059
+ ) {
1060
+ throw new Error(`Memory dream has an invalid pending disposition for ${id}.`);
1061
+ }
1062
+ seen.add(id);
1063
+ if (status === "retain") {
1064
+ const lineIndex = nextLines.indexOf(byId.get(id)!);
1065
+ if (lineIndex === -1) {
1066
+ throw new Error(
1067
+ `Memory dream must preserve retained pending item ${id} verbatim in pending.md.`,
1068
+ );
1069
+ }
1070
+ nextLines.splice(lineIndex, 1);
1071
+ }
1072
+ }
1073
+ if (seen.size !== items.length) {
1074
+ throw new Error("Memory dream is missing pending dispositions for existing items.");
1017
1075
  }
1018
-
1019
- return !mentionsResolution(summary);
1020
- }
1021
-
1022
- function mentionsResolution(text: string): boolean {
1023
- return /\b(resolve|resolved|abandon|abandoned|drop|dropped|complete|completed|close|closed)\b/i.test(
1024
- text,
1025
- );
1026
1076
  }
1027
1077
 
1028
1078
  function buildMemoryPrompt(readme: string, blocks: CoreBlocks, researchFiles: string[]): string {
@@ -1078,9 +1128,11 @@ function buildDefaultMemoryReadme(): string {
1078
1128
  "",
1079
1129
  "Dream is the only consolidation mechanism for `core/`.",
1080
1130
  "",
1131
+ "Dream requires an explicit retain, complete, or abandon disposition for every nonblank line of existing `pending.md`. Retained lines must remain verbatim; prose in a summary cannot authorize deleting pending work.",
1132
+ "",
1081
1133
  "Pi can trigger dreaming automatically on session start when logs are stale and after compaction when new compaction context should be folded into memory. You can also run `/memory dream` manually.",
1082
1134
  "",
1083
- "Dream reads all log entries newer than the last dreamed log timestamp plus pending compaction summaries. It does not automatically retrieve older log entries.",
1135
+ "Dream reads log entries after the append cursor in `state.json` plus pending compaction summaries. Timestamps are provenance, not replay boundaries. The cursor includes a hash of the consumed log prefix; restore the log if that prefix changes. It does not automatically retrieve consumed entries.",
1084
1136
  "",
1085
1137
  ].join("\n");
1086
1138
  }
@@ -1258,6 +1310,7 @@ function defaultMemoryState(): MemoryState {
1258
1310
  lastLogAt: null,
1259
1311
  lastDreamAt: null,
1260
1312
  lastDreamedLogAt: null,
1313
+ lastDreamedLogCursor: null,
1261
1314
  pendingCompactions: [],
1262
1315
  };
1263
1316
  }
@@ -1382,21 +1435,44 @@ function parseMemoryLog(text: string): LogEntry[] {
1382
1435
  }
1383
1436
 
1384
1437
  flush();
1385
- return entries.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
1438
+ return entries;
1386
1439
  }
1387
1440
 
1388
- function selectUndreamedLogEntries(
1389
- entries: LogEntry[],
1390
- lastDreamedLogAt: string | null,
1391
- ): LogEntry[] {
1392
- if (!lastDreamedLogAt) {
1393
- return [...entries];
1441
+ function selectUndreamedLogEntries(logText: string, state: MemoryState): LogEntry[] {
1442
+ const cursor = state.lastDreamedLogCursor;
1443
+ const log = Buffer.from(logText, "utf8");
1444
+ if (
1445
+ cursor &&
1446
+ (cursor.bytes > log.length ||
1447
+ getLogCursor(log.subarray(0, cursor.bytes)).sha256 !== cursor.sha256)
1448
+ ) {
1449
+ throw new Error(
1450
+ "Memory log changed before its dream cursor. Restore log.md or explicitly reset last_dreamed_log_cursor to null to replay the entire log.",
1451
+ );
1394
1452
  }
1395
- return entries.filter((entry) => entry.timestamp > lastDreamedLogAt);
1453
+ return parseMemoryLog(log.subarray(cursor?.bytes ?? 0).toString("utf8"));
1396
1454
  }
1397
1455
 
1398
- function countUndreamedLogs(entries: LogEntry[], lastDreamedLogAt: string | null): number {
1399
- return selectUndreamedLogEntries(entries, lastDreamedLogAt).length;
1456
+ function getLogCursor(log: Buffer): LogCursor {
1457
+ return { bytes: log.length, sha256: createHash("sha256").update(log).digest("hex") };
1458
+ }
1459
+
1460
+ function parseLogCursor(input: unknown): LogCursor | null {
1461
+ if (input === undefined || input === null) return null;
1462
+ if (typeof input !== "object" || Array.isArray(input)) {
1463
+ throw new Error("Invalid last_dreamed_log_cursor.");
1464
+ }
1465
+ const cursor = input as Record<string, unknown>;
1466
+ if (
1467
+ typeof cursor.bytes !== "number" ||
1468
+ !Number.isSafeInteger(cursor.bytes) ||
1469
+ cursor.bytes < 0 ||
1470
+ typeof cursor.sha256 !== "string" ||
1471
+ !/^[a-f0-9]{64}$/.test(cursor.sha256)
1472
+ ) {
1473
+ throw new Error("Invalid last_dreamed_log_cursor.");
1474
+ }
1475
+ return { bytes: cursor.bytes, sha256: cursor.sha256 };
1400
1476
  }
1401
1477
 
1402
1478
  function parseLogEntryBody(body: string): {
@@ -1706,7 +1782,7 @@ export default function memoryExtension(pi: ExtensionAPI): void {
1706
1782
  promptSnippet: `Update one core memory block in .agents/memory/core with ${CORE_LINE_CAP}-line and ${CORE_CHAR_CAP}-character cap enforcement`,
1707
1783
  promptGuidelines: [
1708
1784
  "Use memory_update_block when updating .agents/memory/core/directives.md, context.md, focus.md, or pending.md.",
1709
- `If a write would exceed the ${CORE_LINE_CAP}-line or ${CORE_CHAR_CAP}-character core cap, run memory_dream or remove content first.`,
1785
+ `If memory_update_block would exceed the ${CORE_LINE_CAP}-line or ${CORE_CHAR_CAP}-character core cap, use memory_dream for consolidation. Do not discard unresolved work to fit.`,
1710
1786
  ],
1711
1787
  parameters: MEMORY_UPDATE_BLOCK_PARAMS,
1712
1788
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
@@ -1737,7 +1813,6 @@ export default function memoryExtension(pi: ExtensionAPI): void {
1737
1813
  promptSnippet: "Append an entry to the repo memory log at .agents/memory/log.md",
1738
1814
  promptGuidelines: [
1739
1815
  "Use memory_append_log for important decisions, discoveries, plans, experiments, prompt ingests, and attachment additions worth remembering.",
1740
- "Use importance labels high, medium, or low.",
1741
1816
  "If this entry replaces or corrects prior memory, set supersedes and/or invalidates links explicitly.",
1742
1817
  "The memory log is append-only. Do not rewrite or truncate older entries.",
1743
1818
  ],
@@ -1770,7 +1845,7 @@ export default function memoryExtension(pi: ExtensionAPI): void {
1770
1845
  name: "memory_dream",
1771
1846
  label: "Memory Dream",
1772
1847
  description:
1773
- "Consolidate repo memory into .agents/memory/core from newer log entries and compaction context",
1848
+ "Consolidate repo memory into .agents/memory/core from undreamed log entries and compaction context",
1774
1849
  promptSnippet: "Consolidate repo memory with dream-based core compression",
1775
1850
  promptGuidelines: [
1776
1851
  "Dream is the only consolidation mechanism for .agents/memory/core.",
@@ -5,7 +5,7 @@
5
5
  * - Agent finished and ready for the next prompt
6
6
  * - Extension prompt is waiting for a question/confirmation answer
7
7
  * Supports multiple terminal protocols:
8
- * - OSC 777: Ghostty, iTerm2, WezTerm, rxvt-unicode
8
+ * - OSC 9: Ghostty, Supacode, iTerm2, WezTerm
9
9
  * - OSC 99: Kitty
10
10
  * - Windows toast: Windows Terminal (WSL)
11
11
  */
@@ -29,8 +29,8 @@ function windowsToastScript(title: string, body: string): string {
29
29
  ].join("; ");
30
30
  }
31
31
 
32
- function notifyOSC777(title: string, body: string): void {
33
- process.stdout.write(`\x1b]777;notify;${title};${body}\x07`);
32
+ function notifyOSC9(title: string, body: string): void {
33
+ process.stdout.write(`\x1b]9;${title}: ${body}\x1b\\`);
34
34
  }
35
35
 
36
36
  function notifyOSC99(title: string, body: string): void {
@@ -51,18 +51,10 @@ function notify(title: string, body: string): void {
51
51
  } else if (process.env.KITTY_WINDOW_ID) {
52
52
  notifyOSC99(title, body);
53
53
  } else {
54
- notifyOSC777(title, body);
54
+ notifyOSC9(title, body);
55
55
  }
56
56
  }
57
57
 
58
- function getPromptSource(event: unknown): string | undefined {
59
- if (!event || typeof event !== "object") return undefined;
60
- const source = (event as Record<string, unknown>).source;
61
- if (typeof source !== "string") return undefined;
62
- const trimmed = source.trim();
63
- return trimmed.length > 0 ? trimmed : undefined;
64
- }
65
-
66
58
  function getSessionKey(ctx: ExtensionContext): string {
67
59
  return ctx.sessionManager.getSessionFile() ?? `session:${ctx.sessionManager.getSessionId()}`;
68
60
  }
@@ -89,7 +81,7 @@ function extractReviewOutcome(data: unknown): "success" | "failed" | "cancelled"
89
81
  }
90
82
 
91
83
  export default function (pi: ExtensionAPI) {
92
- let pendingPromptCount = 0;
84
+ let promptPending = false;
93
85
  const activeReviewSessions = new Set<string>();
94
86
  let currentSessionKey: string | undefined;
95
87
  let pendingReadyNotification: ReturnType<typeof setImmediate> | undefined;
@@ -110,7 +102,7 @@ export default function (pi: ExtensionAPI) {
110
102
  pendingReadyNotification = setImmediate(() => {
111
103
  pendingReadyNotification = undefined;
112
104
  if (!ctx.isIdle()) return;
113
- if (pendingPromptCount > 0) return;
105
+ if (promptPending) return;
114
106
  if (hasCurrentSessionReviewRun()) return;
115
107
  notify("Pi", "Ready for input");
116
108
  });
@@ -122,18 +114,13 @@ export default function (pi: ExtensionAPI) {
122
114
  currentSessionKey = getSessionKey(ctx);
123
115
  });
124
116
 
125
- pi.events.on("ui:prompt_start", (data) => {
126
- const wasIdle = pendingPromptCount === 0;
127
- pendingPromptCount += 1;
128
-
129
- if (!wasIdle) return;
130
- const source = getPromptSource(data);
131
- notify("Pi", source ? `Question pending (${source})` : "Question pending");
117
+ pi.on("ui_prompt_start", async () => {
118
+ promptPending = true;
119
+ notify("Pi", "Waiting for input");
132
120
  });
133
121
 
134
- pi.events.on("ui:prompt_end", () => {
135
- if (pendingPromptCount === 0) return;
136
- pendingPromptCount -= 1;
122
+ pi.on("ui_prompt_end", async () => {
123
+ promptPending = false;
137
124
  });
138
125
 
139
126
  pi.events.on(REVIEW_EVENT_START, (data) => {
@@ -148,8 +135,9 @@ export default function (pi: ExtensionAPI) {
148
135
 
149
136
  activeReviewSessions.delete(sessionKey);
150
137
 
151
- if (pendingPromptCount > 0) return;
152
138
  if (!currentSessionKey || sessionKey !== currentSessionKey) return;
139
+ cancelReadyNotification();
140
+ if (promptPending) return;
153
141
 
154
142
  const outcome = extractReviewOutcome(data);
155
143
  if (outcome === "success") {
@@ -170,7 +158,7 @@ export default function (pi: ExtensionAPI) {
170
158
 
171
159
  pi.on("session_shutdown", async (_event, ctx) => {
172
160
  cancelReadyNotification();
173
- pendingPromptCount = 0;
161
+ promptPending = false;
174
162
  const sessionKey = getSessionKey(ctx);
175
163
  activeReviewSessions.delete(sessionKey);
176
164
  if (currentSessionKey === sessionKey) {