@fitchmultz/pi-subagents 0.50.0 → 0.51.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 (169) hide show
  1. package/CHANGELOG.md +51 -2
  2. package/README.md +36 -5
  3. package/dist/agent/contracts.js +6 -24
  4. package/dist/agent/host.js +3 -16
  5. package/dist/agent/index.js +207 -173
  6. package/dist/agent/journal-tail.js +115 -156
  7. package/dist/agent/launch.js +20 -8
  8. package/dist/agent/legacy.js +65 -23
  9. package/dist/agent/notifications.js +138 -47
  10. package/dist/agent/observation-state.js +246 -0
  11. package/dist/agent/observation-view.js +258 -0
  12. package/dist/agent/observation.js +346 -0
  13. package/dist/agent/observe-card.js +243 -0
  14. package/dist/agent/observe-chrome.js +85 -0
  15. package/dist/agent/observe-client.js +285 -0
  16. package/dist/agent/observe-layout.js +65 -0
  17. package/dist/agent/observe-reading.js +84 -0
  18. package/dist/agent/observe-screen.js +400 -96
  19. package/dist/agent/observe-transcript.js +382 -0
  20. package/dist/agent/observe.js +250 -43
  21. package/dist/agent/process.js +198 -25
  22. package/dist/agent/receipts.js +92 -20
  23. package/dist/agent/runner.js +125 -20
  24. package/dist/agent/shell.js +2 -1
  25. package/dist/agent/storage.js +29 -12
  26. package/dist/agent/tool-schemas.js +20 -0
  27. package/dist/agent/typebox.js +10 -5
  28. package/dist/agent/usage.js +0 -115
  29. package/dist/agent/view.js +46 -16
  30. package/dist/agent/wake.js +99 -0
  31. package/dist/build-info.js +1 -1
  32. package/dist/intercom-client.d.ts +26 -0
  33. package/dist/intercom-client.js +45 -0
  34. package/dist/pi-intercom/agent-dir.d.ts +1 -0
  35. package/dist/pi-intercom/bridge-session.js +2 -1
  36. package/dist/pi-intercom/broker/broker.js +27 -21
  37. package/dist/pi-intercom/broker/client-last.d.ts +13 -0
  38. package/dist/pi-intercom/broker/client-last.js +17 -5
  39. package/dist/pi-intercom/broker/client.d.ts +60 -0
  40. package/dist/pi-intercom/broker/client.js +47 -16
  41. package/dist/pi-intercom/broker/connection.d.ts +29 -0
  42. package/dist/pi-intercom/broker/framing.d.ts +17 -0
  43. package/dist/pi-intercom/broker/framing.js +10 -7
  44. package/dist/pi-intercom/broker/guard.js +23 -0
  45. package/dist/pi-intercom/broker/last.d.ts +26 -0
  46. package/dist/pi-intercom/broker/last.js +57 -32
  47. package/dist/pi-intercom/broker/paths.d.ts +13 -0
  48. package/dist/pi-intercom/broker/paths.js +35 -12
  49. package/dist/pi-intercom/broker/pid.js +5 -1
  50. package/dist/pi-intercom/broker/server-connection.js +19 -0
  51. package/dist/pi-intercom/broker/snapshot-reader.d.ts +9 -0
  52. package/dist/pi-intercom/broker/snapshots.d.ts +29 -0
  53. package/dist/pi-intercom/broker/targets.d.ts +9 -0
  54. package/dist/pi-intercom/connection.js +10 -2
  55. package/dist/pi-intercom/identity.js +6 -5
  56. package/dist/pi-intercom/inbound-delivery.js +16 -60
  57. package/dist/pi-intercom/inbound-journal.js +83 -6
  58. package/dist/pi-intercom/inbound-record.js +18 -1
  59. package/dist/pi-intercom/inbound.js +12 -38
  60. package/dist/pi-intercom/index.js +3 -0
  61. package/dist/pi-intercom/last-message.js +4 -16
  62. package/dist/pi-intercom/lifecycle.js +88 -3
  63. package/dist/pi-intercom/outbound.js +5 -15
  64. package/dist/pi-intercom/reply-tracker.js +7 -6
  65. package/dist/pi-intercom/reply-wait.js +2 -2
  66. package/dist/pi-intercom/session-targets.d.ts +24 -0
  67. package/dist/pi-intercom/tool-registration.js +4 -1
  68. package/dist/pi-intercom/tool-results.js +5 -7
  69. package/dist/pi-intercom/tool-schemas.js +12 -26
  70. package/dist/pi-intercom/types.d.ts +212 -0
  71. package/dist/pi-intercom/types.js +54 -3
  72. package/dist/pi-intercom/validation.d.ts +12 -0
  73. package/dist/pi-intercom/validation.js +3 -0
  74. package/dist/shared/bounded-json.js +151 -0
  75. package/dist/shared/compact-cards.js +66 -0
  76. package/dist/shared/journal-frames.js +3 -6
  77. package/dist/shared/json-projection.js +267 -129
  78. package/dist/shared/native-journal.js +123 -56
  79. package/dist/shared/post-exit-stdio-guard.js +120 -7
  80. package/dist/shared/tui-action.js +29 -0
  81. package/dist/shared/unknown.d.ts +9 -0
  82. package/docs/code-quality.md +2 -0
  83. package/docs/development.md +40 -1
  84. package/docs/intercom-bridge.md +9 -4
  85. package/docs/intercom.md +226 -6
  86. package/docs/performance.md +87 -11
  87. package/docs/reference.md +157 -20
  88. package/docs/retired-history-maintenance.md +252 -0
  89. package/package.json +28 -15
  90. package/scripts/archive-retired-history.mjs +464 -0
  91. package/scripts/build.mjs +48 -28
  92. package/scripts/measure-size.mjs +1 -1
  93. package/scripts/native-package-smoke.mjs +153 -12
  94. package/scripts/package-smoke.mjs +80 -11
  95. package/scripts/prepare.mjs +2 -2
  96. package/scripts/real-pi-live.mjs +377 -0
  97. package/scripts/real-pi-processes.mjs +6 -1
  98. package/scripts/real-pi-smoke.mjs +6 -282
  99. package/scripts/run-tests.mjs +4 -2
  100. package/scripts/typescript.mjs +35 -0
  101. package/skills/pi-intercom/SKILL.md +2 -1
  102. package/skills/pi-subagents/SKILL.md +23 -5
  103. package/src/agent/contracts.ts +8 -34
  104. package/src/agent/host.ts +19 -20
  105. package/src/agent/index.ts +224 -190
  106. package/src/agent/journal-tail.ts +141 -153
  107. package/src/agent/launch.ts +33 -9
  108. package/src/agent/legacy.ts +85 -31
  109. package/src/agent/notifications.ts +192 -67
  110. package/src/agent/observation-state.ts +277 -0
  111. package/src/agent/observation-view.ts +267 -0
  112. package/src/agent/observation.ts +405 -0
  113. package/src/agent/observe-card.ts +304 -0
  114. package/src/agent/observe-chrome.ts +170 -0
  115. package/src/agent/observe-client.ts +318 -0
  116. package/src/agent/observe-layout.ts +79 -0
  117. package/src/agent/observe-reading.ts +97 -0
  118. package/src/agent/observe-screen.ts +443 -129
  119. package/src/agent/observe-transcript.ts +434 -0
  120. package/src/agent/observe.ts +304 -71
  121. package/src/agent/process.ts +240 -34
  122. package/src/agent/receipts.ts +114 -24
  123. package/src/agent/runner.ts +125 -27
  124. package/src/agent/shell.ts +2 -1
  125. package/src/agent/storage.ts +27 -11
  126. package/src/agent/tool-schemas.ts +33 -0
  127. package/src/agent/typebox.ts +12 -7
  128. package/src/agent/usage.ts +0 -130
  129. package/src/agent/view.ts +70 -20
  130. package/src/agent/wake.ts +107 -0
  131. package/src/intercom-client.ts +108 -0
  132. package/src/pi-intercom/bridge-session.ts +2 -2
  133. package/src/pi-intercom/broker/broker.ts +27 -23
  134. package/src/pi-intercom/broker/client-last.ts +25 -12
  135. package/src/pi-intercom/broker/client.ts +55 -33
  136. package/src/pi-intercom/broker/framing.ts +11 -8
  137. package/src/pi-intercom/broker/guard.ts +33 -0
  138. package/src/pi-intercom/broker/last.ts +86 -33
  139. package/src/pi-intercom/broker/paths.ts +39 -12
  140. package/src/pi-intercom/broker/pid.ts +6 -1
  141. package/src/pi-intercom/broker/server-connection.ts +25 -0
  142. package/src/pi-intercom/connection.ts +21 -10
  143. package/src/pi-intercom/identity.ts +8 -5
  144. package/src/pi-intercom/inbound-delivery.ts +20 -66
  145. package/src/pi-intercom/inbound-journal.ts +103 -7
  146. package/src/pi-intercom/inbound-record.ts +21 -4
  147. package/src/pi-intercom/inbound.ts +18 -49
  148. package/src/pi-intercom/index.ts +3 -0
  149. package/src/pi-intercom/last-message.ts +10 -19
  150. package/src/pi-intercom/lifecycle.ts +117 -14
  151. package/src/pi-intercom/outbound.ts +7 -16
  152. package/src/pi-intercom/reply-tracker.ts +8 -6
  153. package/src/pi-intercom/reply-wait.ts +3 -2
  154. package/src/pi-intercom/runtime-types.ts +0 -1
  155. package/src/pi-intercom/tool-registration.ts +4 -1
  156. package/src/pi-intercom/tool-results.ts +10 -9
  157. package/src/pi-intercom/tool-schemas.ts +12 -44
  158. package/src/pi-intercom/types.ts +115 -4
  159. package/src/pi-intercom/validation.ts +4 -0
  160. package/src/shared/bounded-json.ts +162 -0
  161. package/src/shared/compact-cards.ts +118 -0
  162. package/src/shared/journal-frames.ts +3 -19
  163. package/src/shared/json-projection.ts +267 -147
  164. package/src/shared/native-journal.ts +157 -75
  165. package/src/shared/post-exit-stdio-guard.ts +165 -7
  166. package/src/shared/tui-action.ts +39 -0
  167. package/tsconfig.client.json +8 -0
  168. package/tsconfig.json +1 -0
  169. package/src/shared/stream-json.d.ts +0 -10
@@ -1,14 +1,10 @@
1
1
  import * as fs from "node:fs";
2
+ import { NativeJournal, type NativeRecord } from "../shared/native-journal.ts";
3
+ import { nativeProjection } from "../shared/journal-projections.ts";
4
+ import type { Projection } from "../shared/json-projection.ts";
5
+ import { isRecord, recordAt, type UnknownRecord } from "../shared/unknown.ts";
2
6
 
3
- const READ_BYTES = 65536;
4
- const LINE_BYTES = 32768;
5
- const KEEP_LINES = 80;
6
-
7
- function record(value: unknown): value is Record<string, unknown> {
8
- return typeof value === "object" && value !== null && !Array.isArray(value);
9
- }
10
7
  export function plainText(value: string, length = 512): string {
11
- // Journal content is untrusted terminal input, never terminal control sequences.
12
8
  return Array.from(value.slice(0, length))
13
9
  .map((char) => {
14
10
  const code = char.codePointAt(0) ?? 0;
@@ -16,169 +12,161 @@ export function plainText(value: string, length = 512): string {
16
12
  })
17
13
  .join("");
18
14
  }
19
- function contentLines(content: unknown): string[] {
20
- if (typeof content === "string") {
21
- return [plainText(content)];
15
+ const PAGE_CHARS = 32 * 1024;
16
+ const callFields = new Set(["type", "id", "name"]);
17
+ const textFields = new Set(["text", "thinking"]);
18
+ const metadata: Projection = (keys, root) => {
19
+ if (
20
+ keys[0] === "message" &&
21
+ keys[1] === "content" &&
22
+ keys.length > 3 &&
23
+ !callFields.has(String(keys[3]))
24
+ ) {
25
+ return false;
22
26
  }
23
- if (!Array.isArray(content)) {
24
- return [];
27
+ return nativeProjection(keys, root);
28
+ };
29
+ function contentIndex(keys: readonly (string | number)[]): number | undefined {
30
+ if (keys[0] !== "message" || keys[1] !== "content") {
31
+ return undefined;
25
32
  }
26
- return content.slice(0, 12).flatMap((block: unknown) => {
27
- if (!record(block)) {
28
- return [];
29
- }
30
- if (block.type === "text" && typeof block.text === "string") {
31
- return [plainText(block.text)];
33
+ if (keys.length === 2) {
34
+ return -1;
35
+ }
36
+ return typeof keys[2] === "number" && textFields.has(String(keys[3])) ? keys[2] : undefined;
37
+ }
38
+ class TextPage {
39
+ private chars = 0;
40
+ private carry?: { index: number; text: string };
41
+ private readonly text = new Map<number, string>();
42
+ private readonly start: number;
43
+ constructor(page: number) {
44
+ this.start = page * PAGE_CHARS;
45
+ }
46
+ add(index: number, chunk: string): void {
47
+ const previous = this.carry;
48
+ this.carry = undefined;
49
+ if (previous && previous.index !== index) {
50
+ this.take(previous.index, previous.text);
32
51
  }
33
- if (block.type === "toolCall" && typeof block.name === "string") {
34
- return [`tool ${plainText(block.name, 80)} ${plainText(JSON.stringify(block.arguments))}`];
52
+ const text = previous?.index === index ? previous.text + chunk : chunk;
53
+ const last = text.charCodeAt(text.length - 1);
54
+ const pending = last >= 0xd800 && last <= 0xdbff;
55
+ this.take(index, pending ? text.slice(0, -1) : text);
56
+ if (pending) {
57
+ this.carry = { index, text: text.slice(-1) };
35
58
  }
36
- return block.type === "thinking"
37
- ? []
38
- : [`[${typeof block.type === "string" ? plainText(block.type, 40) : "non-text"} omitted]`];
39
- });
40
- }
41
- function activity(value: unknown): string[] {
42
- if (!record(value) || value.type !== "message" || !record(value.message)) {
43
- return [];
44
59
  }
45
- const message = value.message;
46
- if (message.role === "assistant") {
47
- const lines = contentLines(message.content).map((line) => `assistant: ${line}`);
48
- if (typeof message.errorMessage === "string") {
49
- lines.push(`assistant error: ${plainText(message.errorMessage)}`);
60
+ private take(index: number, text: string): void {
61
+ const points = Array.from(text);
62
+ const from = Math.max(0, this.start - this.chars);
63
+ const to = Math.min(points.length, this.start + PAGE_CHARS - this.chars);
64
+ if (to > from) {
65
+ this.text.set(index, (this.text.get(index) ?? "") + points.slice(from, to).join(""));
50
66
  }
51
- return lines;
67
+ this.chars += points.length;
52
68
  }
53
- if (message.role === "toolResult") {
54
- const label = message.isError === true ? "tool error" : "tool result";
55
- const name = typeof message.toolName === "string" ? plainText(message.toolName, 80) : "unknown";
56
- return [`${label} ${name}:`, ...contentLines(message.content)];
69
+ finish(value: UnknownRecord, page: number): ConversationPage {
70
+ if (this.carry) {
71
+ this.take(this.carry.index, this.carry.text);
72
+ }
73
+ const message = { ...recordAt(value, "message") };
74
+ if (typeof message.content === "string") {
75
+ message.content = this.text.get(-1) ?? "";
76
+ }
77
+ if (Array.isArray(message.content)) {
78
+ for (const [index, block] of (message.content as unknown[]).entries()) {
79
+ if (isRecord(block) && typeof block.type === "string" && textFields.has(block.type)) {
80
+ block[block.type] = this.text.get(index) ?? "";
81
+ }
82
+ }
83
+ }
84
+ return { message, page, pages: Math.max(1, Math.ceil(this.chars / PAGE_CHARS)) };
57
85
  }
58
- return [];
86
+ }
87
+ export interface ConversationPage {
88
+ message: UnknownRecord;
89
+ page: number;
90
+ pages: number;
59
91
  }
60
92
 
61
- /** A lossy display tail; the native journal remains the complete source of truth. */
62
- export class JournalTail {
63
- readonly lines: string[] = [];
64
- notice = "Waiting for published native activity.";
65
- private offset = 0;
66
- private partial: Buffer = Buffer.alloc(0);
67
- private dropping = false;
68
- private identity = "";
69
- private stamp = 0;
70
- private readonly file: string;
71
- private readonly sessionId: string;
93
+ /** Native indexing owns ancestry and publication; only requested message pages are hydrated. */
94
+ export class ConversationJournal {
95
+ readonly native: NativeJournal;
96
+ private branch?: { length: number; leaf: string | null | undefined; entries: NativeRecord[] };
72
97
  constructor(file: string, sessionId: string) {
73
- this.file = file;
74
- this.sessionId = sessionId;
75
- }
76
- private reset(size: number): void {
77
- this.lines.length = 0;
78
- this.offset = 0;
79
- this.partial = Buffer.alloc(0);
80
- this.dropping = false;
81
- if (size > READ_BYTES) {
82
- this.skip(size);
83
- }
84
- }
85
- private skip(size: number): void {
86
- this.offset = size - READ_BYTES;
87
- this.partial = Buffer.alloc(0);
88
- this.dropping = true;
89
- this.lines.push("[Earlier native activity omitted; full journal retained.]");
90
- }
91
- private header(fd: number): void {
92
- const head = Buffer.alloc(4096);
93
- const count = fs.readSync(fd, head, 0, head.length, 0);
94
- const lf = head.indexOf(10, 0);
95
- if (lf < 0 || lf >= count) {
96
- throw new Error("Native journal header is incomplete or too large.");
98
+ const stat = fs.lstatSync(file);
99
+ if (!stat.isFile() || stat.isSymbolicLink()) {
100
+ throw new Error("Child journal is not a regular file.");
97
101
  }
98
- const value: unknown = JSON.parse(head.subarray(0, lf).toString("utf8"));
99
- if (!record(value) || value.type !== "session" || value.id !== this.sessionId) {
102
+ this.native = new NativeJournal(file, "live", true, {
103
+ readBytes: 256 * 1024,
104
+ select: metadata,
105
+ });
106
+ if (this.native.records[0]?.value.id !== sessionId) {
100
107
  throw new Error("Native journal identity does not match this child.");
101
108
  }
102
109
  }
103
- private line(line: Buffer): void {
104
- if (line.length > LINE_BYTES) {
105
- this.lines.push("[Large native entry omitted; read the full journal.]");
106
- return;
107
- }
108
- try {
109
- const value: unknown = JSON.parse(line.toString("utf8"));
110
- this.lines.push(...activity(value));
111
- } catch {
112
- this.lines.push("[Malformed published native entry omitted.]");
110
+ entries(requested?: string | null): NativeRecord[] {
111
+ const leaf =
112
+ typeof requested === "string" && !this.native.byId.has(requested) ? undefined : requested;
113
+ if (this.branch?.length !== this.native.records.length || this.branch.leaf !== leaf) {
114
+ this.branch = {
115
+ length: this.native.records.length,
116
+ leaf,
117
+ entries: this.native.branch(leaf).filter((entry) => entry.value.type === "message"),
118
+ };
113
119
  }
120
+ return this.branch.entries;
114
121
  }
115
- private consume(chunk: Buffer): void {
116
- const bytes = Buffer.concat([this.partial, chunk]);
117
- let start = 0;
118
- let lf = bytes.indexOf(10);
119
- while (lf >= 0) {
120
- if (!this.dropping) {
121
- this.line(bytes.subarray(start, lf));
122
- }
123
- this.dropping = false;
124
- start = lf + 1;
125
- lf = bytes.indexOf(10, start);
126
- }
127
- this.partial = bytes.subarray(start);
128
- if (this.partial.length > LINE_BYTES) {
129
- this.partial = Buffer.alloc(0);
130
- this.dropping = true;
131
- this.lines.push("[Large native entry omitted; read the full journal.]");
132
- }
133
- this.lines.splice(0, Math.max(0, this.lines.length - KEEP_LINES));
134
- if (this.partial.length > 0) {
135
- this.notice = "Waiting for newline publication of the latest native entry.";
136
- }
122
+ async call(id: string, result: string, signal?: AbortSignal): Promise<UnknownRecord | undefined> {
123
+ const contains = (entry: NativeRecord) => {
124
+ const content = recordAt(entry.value, "message")?.content;
125
+ return (
126
+ Array.isArray(content) &&
127
+ content.some(
128
+ (part: unknown) => isRecord(part) && part.type === "toolCall" && part.id === id,
129
+ )
130
+ );
131
+ };
132
+ const entries = this.branch?.entries ?? [];
133
+ const entry = entries
134
+ .slice(
135
+ 0,
136
+ entries.findIndex((item) => item.value.id === result),
137
+ )
138
+ .findLast(contains);
139
+ const content = entry && (await this.page(entry, 0, { signal })).message.content;
140
+ return Array.isArray(content)
141
+ ? content.find(
142
+ (part: unknown): part is UnknownRecord =>
143
+ isRecord(part) && part.type === "toolCall" && part.id === id,
144
+ )
145
+ : undefined;
137
146
  }
138
- private changed(stat: fs.Stats, identity: string): boolean {
139
- return (
140
- identity !== this.identity ||
141
- stat.size < this.offset ||
142
- (stat.size === this.offset && stat.mtimeMs !== this.stamp)
147
+ async page(
148
+ entry: NativeRecord,
149
+ page = 0,
150
+ options: { signal?: AbortSignal; hideThinking?: boolean } = {},
151
+ ): Promise<ConversationPage> {
152
+ const text = new TextPage(page);
153
+ const value = await this.native.project(
154
+ entry,
155
+ (keys, root) => {
156
+ if (contentIndex(keys) !== undefined && keys.length > 2) {
157
+ return 0;
158
+ }
159
+ return nativeProjection(keys, root);
160
+ },
161
+ (keys, chunk) => {
162
+ const index = contentIndex(keys);
163
+ if (index === undefined || (options.hideThinking === true && keys[3] === "thinking")) {
164
+ return;
165
+ }
166
+ text.add(index, chunk);
167
+ },
168
+ options.signal,
143
169
  );
144
- }
145
- private observe(fd: number, stat: fs.Stats): void {
146
- const identity = `${stat.dev}:${stat.ino}`;
147
- if (this.changed(stat, identity)) {
148
- this.reset(stat.size);
149
- } else if (stat.size - this.offset > READ_BYTES) {
150
- this.skip(stat.size);
151
- }
152
- this.identity = identity;
153
- this.stamp = stat.mtimeMs;
154
- const chunk = Buffer.alloc(Math.min(READ_BYTES, stat.size - this.offset));
155
- const count = fs.readSync(fd, chunk, 0, chunk.length, this.offset);
156
- this.offset += count;
157
- this.notice =
158
- this.offset > READ_BYTES ? "Recent journal tail only; full journal retained." : "";
159
- this.consume(chunk.subarray(0, count));
160
- if (this.lines.length === 0 && this.notice.length === 0) {
161
- this.notice = "Waiting for published native activity.";
162
- }
163
- }
164
- poll(): void {
165
- let fd: number | undefined;
166
- try {
167
- fd = fs.openSync(this.file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
168
- const stat = fs.fstatSync(fd);
169
- if (!stat.isFile()) {
170
- throw new Error("Native journal is not a regular file.");
171
- }
172
- this.header(fd);
173
- this.observe(fd, stat);
174
- } catch (error) {
175
- this.lines.length = 0;
176
- this.identity = "";
177
- this.notice = error instanceof Error ? plainText(error.message) : "Journal unavailable.";
178
- } finally {
179
- if (fd !== undefined) {
180
- fs.closeSync(fd);
181
- }
182
- }
170
+ return text.finish(value, page);
183
171
  }
184
172
  }
@@ -3,16 +3,22 @@ import * as path from "node:path";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
5
5
  import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
6
- import type { Launch, DelegateInput } from "./contracts.ts";
6
+ import type { Launch } from "./contracts.ts";
7
+ import type { DelegateInput } from "./tool-schemas.ts";
7
8
  import { loadSDK, packageRoot, runtimeEntry } from "./host.ts";
8
9
  import { runDirectory, write } from "./storage.ts";
9
10
 
10
- function selectedModel(input: DelegateInput, context: ExtensionContext) {
11
+ function selectedModel(
12
+ input: DelegateInput,
13
+ context: ExtensionContext,
14
+ thinking: ThinkingLevel,
15
+ sdk: Awaited<ReturnType<typeof loadSDK>>,
16
+ ) {
11
17
  if (input.model === undefined) {
12
18
  if (!context.model) {
13
19
  throw new Error("No selected model; no fallback was used.");
14
20
  }
15
- return context.model;
21
+ return { model: context.model, thinking };
16
22
  }
17
23
  const slash = input.model.indexOf("/");
18
24
  if (slash < 1) {
@@ -22,10 +28,28 @@ function selectedModel(input: DelegateInput, context: ExtensionContext) {
22
28
  input.model.slice(0, slash),
23
29
  input.model.slice(slash + 1),
24
30
  );
25
- if (!model) {
31
+ if (model) {
32
+ return { model, thinking };
33
+ }
34
+ if (typeof sdk.resolveModelReference !== "function") {
35
+ throw new Error(
36
+ `Model ${input.model} is unavailable; no fallback was used. Check the provider/model ID. This host supports literal IDs only. If you intended a thinking suffix, update Pi for native thinking-suffix support.`,
37
+ );
38
+ }
39
+ const resolved = sdk.resolveModelReference(
40
+ {
41
+ text: input.model,
42
+ provider: input.model.slice(0, slash),
43
+ thinkingSuffix: "strict",
44
+ fuzzy: false,
45
+ },
46
+ context.modelRegistry.getAll(),
47
+ () => true,
48
+ );
49
+ if (resolved.status !== "resolved") {
26
50
  throw new Error(`Model ${input.model} is unavailable; no fallback was used.`);
27
51
  }
28
- return model;
52
+ return { model: resolved.model, thinking: resolved.thinkingLevel ?? thinking };
29
53
  }
30
54
  function targetTrust(
31
55
  sdk: Awaited<ReturnType<typeof loadSDK>>,
@@ -63,7 +87,7 @@ export async function prepareLaunch(options: {
63
87
  if (!fs.statSync(cwd).isDirectory()) {
64
88
  throw new Error("Delegation cwd is not a directory.");
65
89
  }
66
- const model = selectedModel(input, context);
90
+ const selection = selectedModel(input, context, thinking, sdk);
67
91
  const trusted = targetTrust(sdk, cwd, context, agentDir);
68
92
  const id = randomUUID();
69
93
  const directory = runDirectory(base, id);
@@ -98,9 +122,9 @@ export async function prepareLaunch(options: {
98
122
  sessionId: manager.getSessionId(),
99
123
  prompt: input.prompt,
100
124
  context: input.context ?? "fresh",
101
- provider: model.provider,
102
- model: model.id,
103
- thinking,
125
+ provider: selection.model.provider,
126
+ model: selection.model.id,
127
+ thinking: selection.thinking,
104
128
  trusted,
105
129
  name: input.name,
106
130
  createdAt: Date.now(),
@@ -1,12 +1,13 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
4
  import { journalStamp } from "../shared/native-journal.ts";
5
5
  import { receiptId } from "./receipts.ts";
6
6
  import { resolveTempRootDir } from "../shared/types.ts";
7
7
  import { isRecord } from "../shared/unknown.ts";
8
- import { hold, release, isHeld } from "./notifications.ts";
8
+ import { release, isHeld, type Completion } from "./notifications.ts";
9
9
  import { missing } from "./storage.ts";
10
+ import { errorText } from "./host.ts";
10
11
 
11
12
  interface LegacyInput {
12
13
  file: string;
@@ -17,29 +18,53 @@ interface LegacyInput {
17
18
  owner: string;
18
19
  single: boolean;
19
20
  }
20
- function legacyInput(file: string, owner: string): LegacyInput | undefined {
21
+ interface IgnoredInputs {
22
+ readonly owner: string;
23
+ readonly directory: string;
24
+ readonly ignored: Map<string, string>;
25
+ }
26
+ interface LegacyScan extends IgnoredInputs {
27
+ readonly known: ReadonlyMap<string, string> | undefined;
28
+ }
29
+ // Retain only foreign classifications, never their saved output, for each live native manager.
30
+ const ignoredInputs = new WeakMap<ExtensionContext["sessionManager"], IgnoredInputs>();
31
+ function fileStamp(file: string): string | undefined {
21
32
  const stat = fs.lstatSync(file, { bigint: true });
22
- if (!stat.isFile() || stat.isSymbolicLink()) {
33
+ return stat.isFile() && !stat.isSymbolicLink() ? journalStamp(stat) : undefined;
34
+ }
35
+ function legacyInput(file: string, scan: LegacyScan): LegacyInput | undefined {
36
+ const stamp = fileStamp(file);
37
+ if (stamp === undefined) {
38
+ return;
39
+ }
40
+ if (scan.known?.get(file) === stamp) {
41
+ scan.ignored.set(file, stamp);
23
42
  return;
24
43
  }
25
44
  const raw = fs.readFileSync(file, "utf8");
26
45
  const data: unknown = JSON.parse(raw);
27
- if (!isRecord(data) || data.sessionId !== owner) {
46
+ if (!isRecord(data) || data.sessionId !== scan.owner) {
47
+ if (fileStamp(file) === stamp) {
48
+ scan.ignored.set(file, stamp);
49
+ }
28
50
  return;
29
51
  }
52
+ return ownedInput({ file, raw, stamp, owner: scan.owner }, data);
53
+ }
54
+ function ownedInput(
55
+ source: Pick<LegacyInput, "file" | "raw" | "stamp" | "owner">,
56
+ data: Record<string, unknown>,
57
+ ): LegacyInput | undefined {
30
58
  const id = typeof data.runId === "string" ? data.runId : data.id;
31
- if (typeof id !== "string" || path.basename(file, ".json") !== id) {
59
+ if (typeof id !== "string" || path.basename(source.file, ".json") !== id) {
32
60
  return;
33
61
  }
34
62
  const legacy = `legacy:${id}:${typeof data.timestamp === "number" ? data.timestamp : "unknown"}`;
35
63
  const completionId = typeof data.completionId === "string" ? data.completionId : legacy;
36
64
  return {
37
- file,
38
- raw,
39
- stamp: journalStamp(stat),
65
+ ...source,
40
66
  id,
41
67
  key: `completion:${completionId}`,
42
- owner,
43
68
  single: data.mode === "single",
44
69
  };
45
70
  }
@@ -104,7 +129,7 @@ function published(ctx: ExtensionContext, input: LegacyInput): boolean {
104
129
  undefined
105
130
  );
106
131
  }
107
- function deliver(pi: ExtensionAPI, ctx: ExtensionContext, input: LegacyInput): void {
132
+ function notification(ctx: ExtensionContext, input: LegacyInput): Completion | undefined {
108
133
  const identity = `legacy:${input.id}:${input.key}`;
109
134
  if (published(ctx, input)) {
110
135
  release(input.owner, identity);
@@ -120,26 +145,54 @@ function deliver(pi: ExtensionAPI, ctx: ExtensionContext, input: LegacyInput): v
120
145
  if (isHeld(input.owner, identity)) {
121
146
  return;
122
147
  }
123
- hold(input.owner, identity);
148
+ return {
149
+ owner: input.owner,
150
+ id: identity,
151
+ message: {
152
+ customType: "subagent-notify",
153
+ display: true,
154
+ // Full saved bytes, not a replacement summary. No owner/run record is invented.
155
+ content: input.raw,
156
+ details: { completion: { runId: input.id, key: input.key, ownerSessionId: input.owner } },
157
+ },
158
+ };
159
+ }
160
+ function legacyNotice(
161
+ ctx: ExtensionContext,
162
+ file: string,
163
+ scan: LegacyScan,
164
+ ): Completion | undefined {
124
165
  try {
125
- pi.sendMessage(
126
- {
127
- customType: "subagent-notify",
128
- display: true,
129
- // Full saved bytes, not a replacement summary. No owner/run record is invented.
130
- content: input.raw,
131
- details: { completion: { runId: input.id, key: input.key, ownerSessionId: input.owner } },
132
- },
133
- { triggerTurn: true, deliverAs: "followUp" },
134
- );
166
+ const input = legacyInput(file, scan);
167
+ return input ? notification(ctx, input) : undefined;
135
168
  } catch (error) {
136
- release(input.owner, identity);
137
- throw error;
169
+ console.error(`Skipped legacy completion ${file}: ${errorText(error)}`);
170
+ return;
138
171
  }
139
172
  }
140
- export function notifyLegacy(pi: ExtensionAPI, ctx: ExtensionContext): void {
141
- const root = resolveTempRootDir();
142
- const directory = path.join(root, "async-subagent-results");
173
+ function startScan(ctx: ExtensionContext, directory: string): LegacyScan {
174
+ const manager = ctx.sessionManager;
175
+ const owner = manager.getSessionId();
176
+ const prior = ignoredInputs.get(manager);
177
+ const scan: LegacyScan = {
178
+ directory,
179
+ owner,
180
+ known: prior?.directory === directory && prior.owner === owner ? prior.ignored : undefined,
181
+ ignored: new Map(),
182
+ };
183
+ // Replace even on a failed directory scan; vanished/unsafe inputs keep no cached decision.
184
+ ignoredInputs.set(manager, { directory, owner, ignored: scan.ignored });
185
+ return scan;
186
+ }
187
+ /** Retired releases created this directory at session start, before any of their runners. */
188
+ export function legacyDirectory(): string {
189
+ return path.join(resolveTempRootDir(), "async-subagent-results");
190
+ }
191
+ export function legacyNotifications(ctx: ExtensionContext): Completion[] {
192
+ const notices: Completion[] = [];
193
+ const directory = legacyDirectory();
194
+ const root = path.dirname(directory);
195
+ const scan = startScan(ctx, directory);
143
196
  let names: string[];
144
197
  try {
145
198
  for (const candidate of [root, directory]) {
@@ -151,7 +204,7 @@ export function notifyLegacy(pi: ExtensionAPI, ctx: ExtensionContext): void {
151
204
  names = fs.readdirSync(directory);
152
205
  } catch (error) {
153
206
  if (missing(error)) {
154
- return;
207
+ return notices;
155
208
  }
156
209
  throw error;
157
210
  }
@@ -159,9 +212,10 @@ export function notifyLegacy(pi: ExtensionAPI, ctx: ExtensionContext): void {
159
212
  if (!name.endsWith(".json")) {
160
213
  continue;
161
214
  }
162
- const input = legacyInput(path.join(directory, name), ctx.sessionManager.getSessionId());
163
- if (input) {
164
- deliver(pi, ctx, input);
215
+ const notice = legacyNotice(ctx, path.join(directory, name), scan);
216
+ if (notice) {
217
+ notices.push(notice);
165
218
  }
166
219
  }
220
+ return notices;
167
221
  }