@evandu/super-pi 0.1.137 → 0.1.139

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 (62) hide show
  1. package/agents/super-pi-knowledge/index.ts +11 -13
  2. package/agents/super-pi-memory/index.ts +11 -5
  3. package/bin/super-pi.js +7 -11
  4. package/dist/.super-pi-check-inputs.json +2 -2
  5. package/dist/client/.super-pi-client-sources.json +6 -6
  6. package/dist/client/assets/{CodeMirrorEditor-CkA_5D5r.js → CodeMirrorEditor-H7SHGhdS.js} +2 -2
  7. package/dist/client/assets/{dist-DcdzfM-n.js → dist-2XXLO-IZ.js} +1 -1
  8. package/dist/client/assets/{dist-ufRHdQ8G.js → dist-BEM6tttY.js} +1 -1
  9. package/dist/client/assets/{dist-9Fm8XKu4.js → dist-BfUeRJKr.js} +1 -1
  10. package/dist/client/assets/{dist-CrxmpY93.js → dist-BokzcFLE.js} +1 -1
  11. package/dist/client/assets/{dist-BfEmujEC.js → dist-BoucVzKA.js} +1 -1
  12. package/dist/client/assets/{dist-B02Erd0t.js → dist-BxTGjU84.js} +1 -1
  13. package/dist/client/assets/{dist-DGhdkud4.js → dist-C-MUXK_t.js} +1 -1
  14. package/dist/client/assets/{dist-k6bOz9RS.js → dist-C8NllalC.js} +1 -1
  15. package/dist/client/assets/{dist-C2tSd-AY.js → dist-CSdzvQnf.js} +1 -1
  16. package/dist/client/assets/{dist-BySjislr.js → dist-CUNctZ5v.js} +1 -1
  17. package/dist/client/assets/{dist-B3Mfuv1D.js → dist-CZ9Krsno.js} +1 -1
  18. package/dist/client/assets/{dist-DbovsYeH.js → dist-C_ftUQZN.js} +1 -1
  19. package/dist/client/assets/{dist-j9X7j381.js → dist-CuMnZOGE.js} +1 -1
  20. package/dist/client/assets/{dist-D-LtG92p.js → dist-CukPKrEn.js} +1 -1
  21. package/dist/client/assets/{dist-b9CfAgzG.js → dist-D3Ta0y4e.js} +1 -1
  22. package/dist/client/assets/{dist-BsqzrZ5R.js → dist-DJ8SwQl_.js} +1 -1
  23. package/dist/client/assets/{dist-BqGjijI6.js → dist-DLKVFZhO.js} +1 -1
  24. package/dist/client/assets/{dist-MC2XuZgq.js → dist-Jw-GQ-re.js} +1 -1
  25. package/dist/client/assets/{dist-D_umYm0o.js → dist-NlopeJ5R.js} +1 -1
  26. package/dist/client/assets/{dist-CsNytPug.js → dist-POoQ4j29.js} +1 -1
  27. package/dist/client/assets/{dist-DdVs8Y0X.js → dist-cP2vnTP_.js} +1 -1
  28. package/dist/client/assets/{dist-DL4Q05ts.js → dist-zUJqdbfo.js} +1 -1
  29. package/dist/client/assets/{index-DVmE8H4Y.js → index-B32VZVYx.js} +13 -13
  30. package/dist/client/assets/index-Bdym8BPo.css +1 -0
  31. package/dist/client/index.html +2 -2
  32. package/dist/server/application/conversation-service.d.ts +13 -4
  33. package/dist/server/application/conversation-service.js +112 -61
  34. package/dist/server/application/run-service.d.ts +13 -0
  35. package/dist/server/application/run-service.js +255 -69
  36. package/dist/server/application/scheduled-task-service.js +8 -1
  37. package/dist/server/application/workspace-service.js +16 -1
  38. package/dist/server/feishu-cli.js +15 -4
  39. package/dist/server/file-index.d.ts +30 -0
  40. package/dist/server/file-index.js +49 -4
  41. package/dist/server/files.d.ts +21 -0
  42. package/dist/server/files.js +38 -0
  43. package/dist/server/http-server.js +1455 -1412
  44. package/dist/server/index.js +3 -4
  45. package/dist/server/logging.js +56 -3
  46. package/dist/server/pi-session-pool.d.ts +2 -0
  47. package/dist/server/pi-session-pool.js +1 -0
  48. package/dist/server/pi-session.d.ts +118 -0
  49. package/dist/server/pi-session.js +132 -10
  50. package/dist/server/runtime.d.ts +47 -0
  51. package/dist/server/runtime.js +183 -84
  52. package/dist/server/store.d.ts +44 -1
  53. package/dist/server/store.js +114 -12
  54. package/dist/server/subagent-runs.js +18 -7
  55. package/dist/server/subagents.js +7 -0
  56. package/dist/server/tool-runner.js +13 -4
  57. package/dist/server/types.d.ts +27 -0
  58. package/dist/server/ws-server.d.ts +34 -5
  59. package/dist/server/ws-server.js +170 -46
  60. package/package.json +15 -1
  61. package/scripts/diagnose.mjs +161 -58
  62. package/dist/client/assets/index-FxTrUapG.css +0 -1
@@ -137,10 +137,9 @@ const paths = {
137
137
  const runtime = process.env.SUPER_PI_RUNTIME === "welcome"
138
138
  ? new WelcomeRuntime()
139
139
  : new PiRpcRuntime(join(dataDir, "pi-sessions"), undefined, { dir: initialAgentDirs, promptDir: agentPromptDir }, { endpoint: taskEndpoint, execEndpoint, todoEndpoint, workflowEndpoint, questionEndpoint, toolsEndpoint, documentsEndpoint, scheduledTaskEndpoint, uploadsDir });
140
- // THE CLIENT GETS THE VISIBLE STATE, NOT THE WHOLE STORE. `clientState()` is `snapshot()` minus the
141
- // workspaces that were taken out of the sidebar - they are still in the store (that is what lets the
142
- // same folder be added back with its history), but they are not part of the app any more.
143
- const broadcaster = new WsBroadcaster(() => store.clientState());
140
+ // Closed workspaces stay in the store for reopening. Focused sockets receive all visible sidebar
141
+ // summaries and only their selected transcript; legacy sockets retain the complete visible state.
142
+ const broadcaster = new WsBroadcaster((scope) => store.clientState(scope));
144
143
  const memory = new ActiveMemory(join(dataDir, "memories"), store, runtime);
145
144
  process.env.SUPER_PI_MEMORY_ENDPOINT = `http://${internalHost}:${port}/api/memory/tool`;
146
145
  if (runtime instanceof PiRpcRuntime)
@@ -69,11 +69,22 @@ export function installServerLogging(dataDir) {
69
69
  * server cannot even write "I died". This file answers the other half of the question: it is
70
70
  * touched every minute while the server lives, so its timestamp says "the server was alive until
71
71
  * this moment" - which is how a silent death gets a time on it.
72
+ *
73
+ * IT ALSO CARRIES THE MEMORY FINGERPRINT, because "the server died without a word" has two very
74
+ * different causes and they need different answers. A server killed by the operating system's OOM
75
+ * killer leaves a log that ends mid-sentence and nothing else - the kernel does not run JavaScript,
76
+ * and the message Node prints about a heap it could not grow goes to a standard error stream this
77
+ * process was started without. The last heartbeat before the silence is the only record of how much
78
+ * memory the process was holding when it went, so it says: the resident size, the heap in use and
79
+ * the heap that had been reserved. A death at 2 GB of resident size is a memory story; a death at
80
+ * 90 MB is something else.
72
81
  */
73
82
  const heartbeatFile = join(logDir, "heartbeat.txt");
74
83
  const touch = () => {
75
84
  try {
76
- writeFileSync(heartbeatFile, `${Date.now()}\n`, "utf8");
85
+ const memory = process.memoryUsage();
86
+ const mb = (bytes) => Math.round(bytes / 1_048_576);
87
+ writeFileSync(heartbeatFile, `${Date.now()} rss=${mb(memory.rss)}MB heapUsed=${mb(memory.heapUsed)}MB heapTotal=${mb(memory.heapTotal)}MB external=${mb(memory.external)}MB\n`, "utf8");
77
88
  }
78
89
  catch { /* best effort */ }
79
90
  };
@@ -114,8 +125,21 @@ export function installServerLogging(dataDir) {
114
125
  write("fatal", `uncaughtException: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
115
126
  process.exit(1);
116
127
  });
128
+ /**
129
+ * AN UNHANDLED REJECTION IS NOT FATAL, AND THE WORD MATTERS.
130
+ *
131
+ * Node hands an unhandled rejection to this hook and KEEPS RUNNING - the process does not die here.
132
+ * It was logged as `fatal` anyway, which made every one of them read like a crash report. One user
133
+ * report of "Super Pi crashed" turned out to be exactly this: a promise nobody was awaiting,
134
+ * refusing a path, once per agent write - and the log's `fatal` line is what made it look like the
135
+ * server had died.
136
+ *
137
+ * The line stays loud, because an unhandled rejection IS a bug - a promise that rejected with nobody
138
+ * listening - but it now says what actually happened, so the next reader is not sent looking for a
139
+ * dead process that is still alive.
140
+ */
117
141
  process.on("unhandledRejection", (reason) => {
118
- write("fatal", `unhandledRejection: ${reason instanceof Error ? (reason.stack ?? reason.message) : String(reason)}`);
142
+ write("error", `unhandledRejection (the process is still running; a promise rejected with nobody listening): ${reason instanceof Error ? (reason.stack ?? reason.message) : String(reason)}`);
119
143
  });
120
144
  process.on("exit", (code) => {
121
145
  write("info", `server process exiting with code ${code}`);
@@ -123,8 +147,37 @@ export function installServerLogging(dataDir) {
123
147
  return { logFile, heartbeatFile, write };
124
148
  }
125
149
  function safeStringify(value) {
150
+ // JSON.stringify(new Error("boom")) is `{}` because Error's useful fields are
151
+ // non-enumerable. That used to erase the only clue in a detached server's log
152
+ // whenever a service did `console.error(error)`. Keep the diagnostic shape
153
+ // explicit and handle the other values that commonly make logging itself throw
154
+ // (cycles and BigInt).
155
+ if (value instanceof Error)
156
+ return value.stack ?? `${value.name}: ${value.message}`;
157
+ if (value === undefined)
158
+ return "undefined";
159
+ if (value === null)
160
+ return "null";
161
+ if (typeof value === "string")
162
+ return value;
163
+ if (typeof value === "bigint")
164
+ return `${value}n`;
126
165
  try {
127
- return JSON.stringify(value);
166
+ const seen = new WeakSet();
167
+ const text = JSON.stringify(value, (_key, nested) => {
168
+ if (nested instanceof Error) {
169
+ return { name: nested.name, message: nested.message, stack: nested.stack, cause: nested.cause };
170
+ }
171
+ if (typeof nested === "bigint")
172
+ return `${nested}n`;
173
+ if (nested && typeof nested === "object") {
174
+ if (seen.has(nested))
175
+ return "[Circular]";
176
+ seen.add(nested);
177
+ }
178
+ return nested;
179
+ });
180
+ return text ?? String(value);
128
181
  }
129
182
  catch {
130
183
  return String(value);
@@ -25,6 +25,8 @@ import { type PiSessionFactory, type PiSessionLike } from "./pi-session.js";
25
25
  export interface SessionRequest {
26
26
  conversationId: string;
27
27
  cwd: string;
28
+ /** The recorded transcript locator, verified against the real header before use. */
29
+ sessionFile?: string;
28
30
  /** The agent's id, or null. */
29
31
  agentId: string | null;
30
32
  /** The environment Super Pi's extensions read when they are loaded. */
@@ -187,6 +187,7 @@ export class PiSessionPool {
187
187
  cwd: request.cwd,
188
188
  sessionId: request.conversationId,
189
189
  sessionDir: this.sessionDir,
190
+ ...(request.sessionFile ? { sessionFile: request.sessionFile } : {}),
190
191
  extensionPaths: superPiExtensionPaths(this.repositoryRoot),
191
192
  skillPaths: superPiSkillPaths(this.repositoryRoot),
192
193
  extensionEnv: request.extensionEnv,
@@ -23,6 +23,85 @@
23
23
  * the transcript on disk while the live session still holds the old context in memory, and nothing in Pi
24
24
  * would notice the difference.
25
25
  */
26
+ /**
27
+ * The Pi SDK, imported lazily.
28
+ *
29
+ * LAZY, and not for startup speed: Super Pi is also shipped as a package that runs against a Pi
30
+ * INSTALLED ON THE USER'S MACHINE, resolved by `pi-command.ts` (override -> PATH -> npm's global bin).
31
+ * That resolution finds a COMMAND; the SDK is a LIBRARY, and it has to come from somewhere specific.
32
+ * A static import would make the whole server fail to start when the library is absent, taking the
33
+ * terminal, the file panel and the git panel down with it - none of which need Pi.
34
+ *
35
+ * So the library is optional, and its absence is a REPORTED FACT rather than a crash. A Super Pi
36
+ * without the SDK cannot run a conversation, and says so in words; a Super Pi whose import throws at
37
+ * module scope cannot even start.
38
+ */
39
+ /**
40
+ * The parts of Pi's SDK this file uses, described structurally.
41
+ *
42
+ * NOT `typeof import("@earendil-works/pi-coding-agent")`, and that is deliberate. The SDK is an
43
+ * OPTIONAL dependency: a Super Pi installed without it still has to COMPILE, because the compiler runs
44
+ * before anything can report that the library is missing. A type-only import of an absent package is a
45
+ * compile error, so the shapes are declared here instead, and the compiler checks them against the
46
+ * installed SDK's own declarations when it is present (see `scripts/pi-session.test.mjs`).
47
+ */
48
+ interface PiSdk {
49
+ DefaultResourceLoader: new (options: {
50
+ cwd: string;
51
+ agentDir: string;
52
+ additionalExtensionPaths?: string[];
53
+ additionalSkillPaths?: string[];
54
+ systemPrompt?: string;
55
+ appendSystemPrompt?: string[];
56
+ extensionFactories?: unknown[];
57
+ }) => {
58
+ reload(): Promise<void>;
59
+ getPrompts?: () => {
60
+ prompts: Array<{
61
+ name: string;
62
+ description: string;
63
+ argumentHint?: string;
64
+ sourceInfo: {
65
+ scope: string;
66
+ };
67
+ content: string;
68
+ filePath: string;
69
+ }>;
70
+ };
71
+ };
72
+ SessionManager: {
73
+ create(cwd: string, sessionDir?: string, options?: {
74
+ id?: string;
75
+ }): unknown;
76
+ open(path: string, sessionDir?: string, cwdOverride?: string): unknown;
77
+ inMemory(cwd?: string, options?: {
78
+ id?: string;
79
+ }): unknown;
80
+ findById(cwd: string, id: string, sessionDir?: string): string | undefined;
81
+ };
82
+ ModelRuntime: {
83
+ create(): Promise<{
84
+ getModel?: (provider: string, modelId: string) => unknown;
85
+ getAvailable?: () => Promise<unknown[]>;
86
+ getModels?: () => unknown[];
87
+ /**
88
+ * Pi's own verdict on its model configuration - the schema error that empties every picker
89
+ * when one entry in `models.json` is refused.
90
+ */
91
+ getError?: () => string | undefined;
92
+ }>;
93
+ };
94
+ SettingsManager: {
95
+ create(cwd: string, agentDir: string): {
96
+ getDefaultProvider?: () => string | undefined;
97
+ getDefaultModel?: () => string | undefined;
98
+ getDefaultThinkingLevel?: () => string | undefined;
99
+ };
100
+ };
101
+ createAgentSession(options: Record<string, unknown>): Promise<{
102
+ session: unknown;
103
+ }>;
104
+ }
26
105
  /**
27
106
  * Loads Pi's SDK while the server is still starting.
28
107
  *
@@ -203,7 +282,10 @@ export interface PiSessionLike {
203
282
  prompt(message: string, options?: {
204
283
  images?: unknown[];
205
284
  streamingBehavior?: "steer" | "followUp";
285
+ preflightResult?: (success: boolean) => void;
206
286
  }): Promise<void>;
287
+ /** True when no agent run or compaction remains; also true when an input hook handled the prompt. */
288
+ readonly isIdle?: boolean;
207
289
  /**
208
290
  * Queues a message to be answered AFTER the current run finishes its pending work.
209
291
  *
@@ -341,6 +423,8 @@ export interface SessionCreation {
341
423
  sessionId: string;
342
424
  /** The directory Pi keeps its session files in. */
343
425
  sessionDir: string;
426
+ /** Previously recorded locator. Its actual header must still match this id and workspace. */
427
+ sessionFile?: string;
344
428
  /** Super Pi's extensions to mount, as absolute paths. */
345
429
  extensionPaths: string[];
346
430
  /** Super Pi's built-in skills to expose beside the user's and workspace's skills. */
@@ -391,6 +475,39 @@ export interface SessionCreation {
391
475
  */
392
476
  export type PiSessionFactory = (creation: SessionCreation) => Promise<PiSessionLike>;
393
477
  export declare function createPiSession(creation: SessionCreation): Promise<PiSessionLike>;
478
+ /**
479
+ * Resolves the session manager for a conversation: the EXISTING transcript if there is one, a new one
480
+ * otherwise.
481
+ *
482
+ * WHY THIS IS NOT JUST `SessionManager.create`. A conversation's transcript outlives its session - Pi
483
+ * writes it to a file, and a session that was evicted (idle, or thrown away by a revert) is rebuilt from
484
+ * that file. So building a session is "reopen the conversation's transcript", and the id is what says
485
+ * which transcript: Super Pi's conversation id IS the session id, which is what makes `revert` and
486
+ * `fork` able to find the file without guessing at a filename.
487
+ *
488
+ * The recorded locator avoids directory discovery altogether. Both paths verify the actual header's
489
+ * exact id and cwd; a stale path or a workspace change must never reopen somebody else's transcript.
490
+ */
491
+ export declare function resolveSessionManager(sdk: Pick<PiSdk, "SessionManager">, creation: SessionCreation): Promise<unknown>;
492
+ type TranscriptHeader = {
493
+ type: "session";
494
+ id: string;
495
+ cwd?: string;
496
+ };
497
+ /** Pi's bounded header discovery contract, using async reads instead of synchronous disk I/O. */
498
+ export declare function readTranscriptHeader(path: string): Promise<TranscriptHeader | undefined>;
499
+ /**
500
+ * Session headers are immutable under Pi's append/rewrite contract. Cache their identities, refresh
501
+ * filenames every lookup, and recheck the selected header before opening. A newly created file is
502
+ * discovered even if stopping/crashing prevented Super Pi from persisting its locator. Arbitrary
503
+ * legacy filenames still work; filenames are never treated as proof of session identity.
504
+ */
505
+ export declare class TranscriptFileIndex {
506
+ private readonly readHeader;
507
+ private readonly directories;
508
+ constructor(readHeader?: (path: string) => Promise<TranscriptHeader | undefined>);
509
+ find(cwd: string, id: string, sessionDir: string): Promise<string | undefined>;
510
+ }
394
511
  /**
395
512
  * Pi's own global configuration directory, when it can be worked out without importing the SDK.
396
513
  *
@@ -424,3 +541,4 @@ export declare function loadPromptTemplateSummaries(cwd: string): Promise<Prompt
424
541
  export declare function superPiExtensionPaths(repositoryRoot: string): string[];
425
542
  /** Skills shipped by Super Pi itself. Paths are explicit so installed and development layouts agree. */
426
543
  export declare function superPiSkillPaths(repositoryRoot: string): string[];
544
+ export {};
@@ -24,7 +24,9 @@
24
24
  * would notice the difference.
25
25
  */
26
26
  import { existsSync, statSync } from "node:fs";
27
- import { dirname, join } from "node:path";
27
+ import { open, readdir } from "node:fs/promises";
28
+ import { StringDecoder } from "node:string_decoder";
29
+ import { dirname, join, resolve } from "node:path";
28
30
  import { fileURLToPath, pathToFileURL } from "node:url";
29
31
  const traceSession = (conversationId, phase, startedAt, extra = "") => {
30
32
  if (process.env.SUPER_PI_TRACE_LATENCY !== "1")
@@ -153,8 +155,10 @@ function sessionLevels(session) {
153
155
  * to guess: a wrong path here would truncate somebody else's conversation.
154
156
  */
155
157
  export async function resolveTranscriptFile(cwd, conversationId, sessionDir) {
156
- const sdk = await loadSdk();
157
- return sdk.SessionManager.findById(cwd, conversationId, sessionDir);
158
+ // The caller still falls back to RPC when the SDK is unavailable. Discovery itself is async so
159
+ // a cold directory on a cloud filesystem does not block stop/send handling on the event loop.
160
+ await loadSdk();
161
+ return transcriptFiles.find(cwd, conversationId, sessionDir);
158
162
  }
159
163
  /** Whether the SDK could be loaded. Used by the server to report the reason instead of failing a turn. */
160
164
  export async function piSdkAvailable() {
@@ -352,16 +356,21 @@ function transcriptBytes(sessionManager) {
352
356
  * which transcript: Super Pi's conversation id IS the session id, which is what makes `revert` and
353
357
  * `fork` able to find the file without guessing at a filename.
354
358
  *
355
- * The existing file is found by EXACT id, not by listing the directory and matching a name. Pi's own
356
- * lookup (`SessionManager.findById`) is the authority for that, and it also enforces the rule that
357
- * matters here: a session is only found when it was recorded for the SAME working directory. A
358
- * conversation whose workspace changed therefore gets a fresh transcript rather than somebody else's.
359
+ * The recorded locator avoids directory discovery altogether. Both paths verify the actual header's
360
+ * exact id and cwd; a stale path or a workspace change must never reopen somebody else's transcript.
359
361
  */
360
- async function resolveSessionManager(sdk, creation) {
361
- const existing = sdk.SessionManager.findById(creation.cwd, creation.sessionId, creation.sessionDir);
362
+ export async function resolveSessionManager(sdk, creation) {
363
+ const cachedHeader = creation.sessionFile ? await readTranscriptHeader(creation.sessionFile) : undefined;
364
+ const cachedMatches = transcriptMatches(cachedHeader, creation.cwd, creation.sessionId);
365
+ const existing = cachedMatches ? creation.sessionFile
366
+ : await transcriptFiles.find(creation.cwd, creation.sessionId, creation.sessionDir);
362
367
  if (existing) {
363
368
  try {
364
- return sdk.SessionManager.open(existing, creation.sessionDir, creation.cwd);
369
+ const manager = sdk.SessionManager.open(existing, creation.sessionDir, creation.cwd);
370
+ // Check Pi's loaded header too: the file may have changed between async discovery and open.
371
+ const header = manager.getHeader?.();
372
+ if (transcriptMatches(header ?? undefined, creation.cwd, creation.sessionId))
373
+ return manager;
365
374
  }
366
375
  catch {
367
376
  // A file that exists but cannot be opened is not a reason to fail the turn: falling through
@@ -371,6 +380,119 @@ async function resolveSessionManager(sdk, creation) {
371
380
  }
372
381
  return sdk.SessionManager.create(creation.cwd, creation.sessionDir, { id: creation.sessionId });
373
382
  }
383
+ function transcriptMatches(header, cwd, id) {
384
+ return header?.id === id && typeof header.cwd === "string" && header.cwd !== "" && resolve(header.cwd) === resolve(cwd);
385
+ }
386
+ /** Pi's bounded header discovery contract, using async reads instead of synchronous disk I/O. */
387
+ export async function readTranscriptHeader(path) {
388
+ const file = await open(path, "r").catch(() => undefined);
389
+ if (!file)
390
+ return undefined;
391
+ try {
392
+ const decoder = new StringDecoder("utf8");
393
+ const buffer = Buffer.allocUnsafe(4096);
394
+ let pending = "";
395
+ const parse = (line) => {
396
+ if (!line.trim())
397
+ return undefined;
398
+ let value;
399
+ try {
400
+ value = JSON.parse(line);
401
+ }
402
+ catch {
403
+ return undefined;
404
+ }
405
+ if (value && typeof value === "object" && value.type === "session"
406
+ && typeof value.id === "string")
407
+ return value;
408
+ return null;
409
+ };
410
+ let scanned = 0;
411
+ while (scanned < 1024 * 1024) {
412
+ const { bytesRead } = await file.read(buffer, 0, Math.min(buffer.length, 1024 * 1024 - scanned), null);
413
+ if (!bytesRead)
414
+ return parse(pending + decoder.end()) ?? undefined;
415
+ scanned += bytesRead;
416
+ pending += decoder.write(buffer.subarray(0, bytesRead));
417
+ let newline;
418
+ while ((newline = pending.indexOf("\n")) !== -1) {
419
+ const header = parse(pending.slice(0, newline));
420
+ if (header !== undefined)
421
+ return header ?? undefined;
422
+ pending = pending.slice(newline + 1);
423
+ }
424
+ }
425
+ // Match Pi's exact-boundary EOF case without reading an unbounded prefix or transcript body.
426
+ const probe = await file.read(buffer, 0, 1, null);
427
+ return probe.bytesRead ? undefined : parse(pending + decoder.end()) ?? undefined;
428
+ }
429
+ catch {
430
+ // Discovery is best-effort, like Pi's own readSessionHeaderForDiscovery.
431
+ return undefined;
432
+ }
433
+ finally {
434
+ await file.close();
435
+ }
436
+ }
437
+ /**
438
+ * Session headers are immutable under Pi's append/rewrite contract. Cache their identities, refresh
439
+ * filenames every lookup, and recheck the selected header before opening. A newly created file is
440
+ * discovered even if stopping/crashing prevented Super Pi from persisting its locator. Arbitrary
441
+ * legacy filenames still work; filenames are never treated as proof of session identity.
442
+ */
443
+ export class TranscriptFileIndex {
444
+ readHeader;
445
+ directories = new Map();
446
+ constructor(readHeader = readTranscriptHeader) {
447
+ this.readHeader = readHeader;
448
+ }
449
+ async find(cwd, id, sessionDir) {
450
+ const dir = resolve(sessionDir);
451
+ let state = this.directories.get(dir);
452
+ if (!state) {
453
+ state = { headers: new Map(), lane: Promise.resolve() };
454
+ this.directories.set(dir, state);
455
+ }
456
+ const current = state;
457
+ let found;
458
+ const lookup = async () => {
459
+ const names = (await readdir(dir).catch(() => [])).filter(name => name.endsWith(".jsonl"));
460
+ const paths = new Set(names.map(name => join(dir, name)));
461
+ for (const path of current.headers.keys())
462
+ if (!paths.has(path))
463
+ current.headers.delete(path);
464
+ const pending = [...paths].filter(path => !current.headers.has(path));
465
+ let next = 0;
466
+ await Promise.all(Array.from({ length: Math.min(8, pending.length) }, async () => {
467
+ while (next < pending.length) {
468
+ const path = pending[next++];
469
+ const header = await this.readHeader(path);
470
+ // Empty/unreadable files may be in the middle of their first write; retry next time.
471
+ if (header)
472
+ current.headers.set(path, header);
473
+ }
474
+ }));
475
+ for (const path of paths) {
476
+ if (!transcriptMatches(current.headers.get(path), cwd, id))
477
+ continue;
478
+ const header = await this.readHeader(path);
479
+ if (header)
480
+ current.headers.set(path, header);
481
+ else
482
+ current.headers.delete(path);
483
+ if (transcriptMatches(header, cwd, id)) {
484
+ found = path;
485
+ break;
486
+ }
487
+ }
488
+ };
489
+ const run = current.lane.then(lookup, lookup);
490
+ current.lane = run.then(() => { }, () => { });
491
+ await run;
492
+ return found;
493
+ }
494
+ }
495
+ const transcriptFiles = new TranscriptFileIndex();
374
496
  /**
375
497
  * Pi's own global configuration directory, when it can be worked out without importing the SDK.
376
498
  *
@@ -686,6 +686,18 @@ export declare class PiRpcRuntime implements AgentRuntime {
686
686
  * it was started.
687
687
  */
688
688
  private readonly pi;
689
+ /**
690
+ * HOW MANY LIVE SESSIONS THIS CONVERSATION HAS, and whether it has one at all.
691
+ *
692
+ * A reader of this number learns whether a conversation's context is still in Pi's memory. That is
693
+ * the fact a revert has to change: it rewrites the transcript on disk, and a live session keeps the
694
+ * OLD context in memory, so a session that outlived the revert is a model that answers from
695
+ * history the person withdrew - with nothing saying so.
696
+ *
697
+ * It is here because that failure is invisible from the outside: the file is shorter, the state is
698
+ * clean, and only the model knows something the reader cannot see.
699
+ */
700
+ liveSessionCount(conversationId: string): number;
689
701
  canSteer(conversationId: string): boolean;
690
702
  constructor(sessionDir: string, command?: string,
691
703
  /**
@@ -999,6 +1011,26 @@ export declare class PiRpcRuntime implements AgentRuntime {
999
1011
  * separated at.
1000
1012
  */
1001
1013
  private answerTurn;
1014
+ /**
1015
+ * THE ONE-SHOT PATH, WITH THE INLINE THINKING SPLIT OUT OF IT.
1016
+ *
1017
+ * WHY THIS IS A SEPARATE FUNCTION AND NOT A LINE IN `answerTurn`. The reasoning of an inline-thinking
1018
+ * model is a `<think>…</think>` region INSIDE the answer text, so splitting it is not a formatting
1019
+ * step - it is a reading of the text, and the facts it produces ("the thinking started", "the thinking
1020
+ * ended") belong where the text is read.
1021
+ *
1022
+ * WHEN IT WAS FORGOTTEN, IT COST THE PRODUCT A BUG. The splitter used to live one layer up, in
1023
+ * `answer()`, where it covered EVERY path. It was moved down into the SDK session path to fix the
1024
+ * ordering of "the thinking ended" against a tool call (UX-008), and in that move the ONE-SHOT
1025
+ * CONVERSATION path - the path a server without the Pi SDK falls back to, and the path the whole test
1026
+ * suite drives - lost the splitting altogether: the `<think>` tags reached the answer verbatim.
1027
+ * `scripts/inline-thinking.test.mjs` caught it; the SDK path was fine, which is why it took a test
1028
+ * suite running the other path to notice.
1029
+ *
1030
+ * SO EVERY ONE-SHOT PATH GOES THROUGH HERE, and the session path keeps its own splitter inside the
1031
+ * queue's consumer, where the ORDER of "thinking ended" and a tool call is decided.
1032
+ */
1033
+ private answerOneShotWithInlineThinking;
1002
1034
  /**
1003
1035
  * The one-shot path: a fresh child process for a single turn.
1004
1036
  *
@@ -1062,6 +1094,21 @@ export declare class PiRpcRuntime implements AgentRuntime {
1062
1094
  * revert is a rare, deliberate act, and a second Pi answers in well under a second. It buys the
1063
1095
  * elimination of an entire class of "we truncated the wrong file and nothing said so" failures.
1064
1096
  */
1097
+ /**
1098
+ * THE LIVE SESSION MUST GO WHEN A REVERT REWRITES THE TRANSCRIPT.
1099
+ *
1100
+ * A revert rewrites Pi's transcript on disk. A live Pi session holds the OLD context in memory, and
1101
+ * nothing in Pi would notice the difference: the model would keep answering from history the user
1102
+ * has just withdrawn, while `state.json` and the file on disk both said it was gone. Three views of
1103
+ * the conversation, two of them wrong, and no error anywhere. So the session is destroyed and the
1104
+ * next turn rebuilds it from the truncated file.
1105
+ *
1106
+ * THIS IS A CORRECTNESS REQUIREMENT, NOT AN OPTIMIZATION, and it is why it lives in one place
1107
+ * called by EVERY branch of `revert`. It used to be inline in two of the three, and the third -
1108
+ * the one taken when the transcript path was not cached - missed it, so a session reverted before
1109
+ * its path was ever learned kept talking about the withdrawn turn with nothing saying so.
1110
+ */
1111
+ private evictAfterRevert;
1065
1112
  revert({ conversation, cwd, boundaryEntryId, beforeBoundary }: RevertRequest): Promise<RevertOutcome>;
1066
1113
  /**
1067
1114
  * Copies Pi's own transcript for one conversation into a second, independent file under a new id.