@ian-pascoe/pi-codemode 0.2.0 → 0.3.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.
@@ -95,10 +95,6 @@ function schemaRecord(value: CodeModeJsonValue | undefined): CodeModeJsonObject
95
95
  return value !== undefined && isCodeModeJsonObject(value) ? value : undefined;
96
96
  }
97
97
 
98
- function jsonLiteral(value: CodeModeJsonValue): string | undefined {
99
- return JSON.stringify(value);
100
- }
101
-
102
98
  function quotedName(name: string): string {
103
99
  return JSON.stringify(name);
104
100
  }
@@ -139,9 +135,11 @@ function schemaType(
139
135
 
140
136
  const constant = record.const;
141
137
  if (Object.hasOwn(record, "const") && constant !== undefined) {
142
- return jsonLiteral(constant) ?? "unknown";
138
+ return JSON.stringify(constant) ?? "unknown";
143
139
  }
144
- const enumValues = Array.isArray(record.enum) ? record.enum.map(jsonLiteral) : undefined;
140
+ const enumValues = Array.isArray(record.enum)
141
+ ? record.enum.map((value) => JSON.stringify(value))
142
+ : undefined;
145
143
  if (
146
144
  enumValues !== undefined &&
147
145
  enumValues.length > 0 &&
@@ -8,6 +8,11 @@ import type {
8
8
  import type { Usage } from "@earendil-works/pi-ai";
9
9
  import { type Static, Type } from "typebox";
10
10
  import { Value } from "typebox/value";
11
+ import {
12
+ CODEMODE_CONSOLE_METHODS,
13
+ type CodeModeConsoleEntry,
14
+ type CodeModeConsoleMethod,
15
+ } from "./codemode-console-output.js";
11
16
 
12
17
  const CODEMODE_TOOL_NAMES = {
13
18
  execute: "codemode_execute",
@@ -170,6 +175,15 @@ const CodeModeErrorCodeSchema = Type.Unsafe<CodeModeErrorCode>({
170
175
  type: "string",
171
176
  enum: [...CODEMODE_ERROR_CODES],
172
177
  });
178
+ const CodeModeConsoleMethodSchema = Type.Unsafe<CodeModeConsoleMethod>({
179
+ type: "string",
180
+ enum: [...CODEMODE_CONSOLE_METHODS],
181
+ });
182
+ const CodeModeConsoleEntrySchema = Type.Object(
183
+ { method: CodeModeConsoleMethodSchema, text: Type.String() },
184
+ { additionalProperties: false },
185
+ );
186
+ const CodeModeConsoleOutputSchema = Type.Array(CodeModeConsoleEntrySchema, { minItems: 1 });
173
187
 
174
188
  /** Stable error retained by a failed CodeMode result. */
175
189
  export const CodeModeErrorSchema = Type.Object(
@@ -186,6 +200,7 @@ const CodeModeSuccessSchema = Type.Object(
186
200
  sessionId: SessionIdSchema,
187
201
  data: Type.Optional(CodeModeJsonValueSchema),
188
202
  reclaimedSessionId: Type.Optional(SessionIdSchema),
203
+ console: Type.Optional(CodeModeConsoleOutputSchema),
189
204
  },
190
205
  { additionalProperties: false },
191
206
  );
@@ -225,6 +240,7 @@ const CodeModeFailedSchema = Type.Object(
225
240
  result: Type.Literal("failed"),
226
241
  sessionId: SessionIdSchema,
227
242
  error: CodeModeErrorSchema,
243
+ console: Type.Optional(CodeModeConsoleOutputSchema),
228
244
  },
229
245
  { additionalProperties: false },
230
246
  );
@@ -245,6 +261,7 @@ const CodeModeSuccessDetailsSchema = Type.Object(
245
261
  sessionId: SessionIdSchema,
246
262
  data: Type.Optional(CodeModeJsonValueSchema),
247
263
  reclaimedSessionId: Type.Optional(SessionIdSchema),
264
+ console: Type.Optional(CodeModeConsoleOutputSchema),
248
265
  presentation: Type.Optional(CodeModePresentationSnapshotSchema),
249
266
  },
250
267
  { additionalProperties: false },
@@ -262,6 +279,7 @@ const CodeModeFailedDetailsSchema = Type.Object(
262
279
  result: Type.Literal("failed"),
263
280
  sessionId: SessionIdSchema,
264
281
  error: CodeModeErrorSchema,
282
+ console: Type.Optional(CodeModeConsoleOutputSchema),
265
283
  presentation: Type.Optional(CodeModePresentationSnapshotSchema),
266
284
  },
267
285
  { additionalProperties: false },
@@ -276,11 +294,19 @@ export const CodeModeResultDetailsSchema = Type.Union([
276
294
  /** Schema-derived details retained by one session-scoped CodeMode operation. */
277
295
  export type CodeModeResultDetails = Static<typeof CodeModeResultDetailsSchema>;
278
296
 
279
- /** A successful result with optional JSON data. */
280
- export function createCodeModeSuccess(sessionId: string, data?: CodeModeJsonValue): CodeModeResult {
281
- return data === undefined
282
- ? { result: "success", sessionId }
283
- : { result: "success", sessionId, data };
297
+ /** A success with optional data and non-empty Cell Console output; empty Console lists are omitted. */
298
+ export function createCodeModeSuccess(
299
+ sessionId: string,
300
+ data?: CodeModeJsonValue,
301
+ consoleEntries?: readonly CodeModeConsoleEntry[],
302
+ ): CodeModeResult {
303
+ const result =
304
+ data === undefined
305
+ ? { result: "success" as const, sessionId }
306
+ : { result: "success" as const, sessionId, data };
307
+ return consoleEntries === undefined || consoleEntries.length === 0
308
+ ? result
309
+ : { ...result, console: [...consoleEntries] };
284
310
  }
285
311
 
286
312
  /** A polling result for a live Cell. */
@@ -288,13 +314,17 @@ export function createCodeModePending(sessionId: string): CodeModeResult {
288
314
  return { result: "pending", sessionId };
289
315
  }
290
316
 
291
- /** A stable expected failure result. */
317
+ /** A stable expected failure with non-empty Cell Console output; empty Console lists are omitted. */
292
318
  export function createCodeModeFailure(
293
319
  sessionId: string,
294
320
  code: CodeModeErrorCode,
295
321
  message: string,
322
+ consoleEntries?: readonly CodeModeConsoleEntry[],
296
323
  ): CodeModeResult {
297
- return { result: "failed", sessionId, error: { code, message } };
324
+ const result = { result: "failed" as const, sessionId, error: { code, message } };
325
+ return consoleEntries === undefined || consoleEntries.length === 0
326
+ ? result
327
+ : { ...result, console: [...consoleEntries] };
298
328
  }
299
329
 
300
330
  /** A bounded JSON compatibility parse that never invokes getters or `toJSON`. */
@@ -21,6 +21,7 @@ import {
21
21
  import { Type } from "typebox";
22
22
  import { Value } from "typebox/value";
23
23
  import { formatCodeModePresentationData } from "./codemode-presentation-output.js";
24
+ import { formatCodeModeDuration } from "./codemode-session-coordinator.js";
24
25
  import {
25
26
  CodeModeCancelParametersSchema,
26
27
  CodeModeExecuteParametersSchema,
@@ -116,13 +117,6 @@ function shortCodeModeSessionId(sessionId: string): string {
116
117
  /** Resolves one Session ID to the shortest unambiguous CodeMode Transcript label. */
117
118
  export type CodeModeSessionPrefixFormatter = (sessionId: string) => string;
118
119
 
119
- function formatCodeModeDuration(elapsedMs: number): string {
120
- if (elapsedMs < 1_000) return `${elapsedMs}ms`;
121
- if (elapsedMs < 60_000) return `${(elapsedMs / 1_000).toFixed(1)}s`;
122
- const seconds = Math.floor(elapsedMs / 1_000);
123
- return `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, "0")}s`;
124
- }
125
-
126
120
  function pluralizedCodeModeCount(count: number, noun: string): string {
127
121
  return `${count} ${noun}${count === 1 ? "" : "s"}`;
128
122
  }
@@ -252,6 +246,7 @@ function renderCodeModeSummary(
252
246
  formatSessionPrefix: CodeModeSessionPrefixFormatter,
253
247
  ): string {
254
248
  const presentation = details.presentation;
249
+ const consoleEntries = details.result === "pending" ? undefined : details.console;
255
250
  const state = codeModeCellState(toolName, details);
256
251
  const status =
257
252
  details.result === "failed" && details.error.code === "eviction"
@@ -279,6 +274,9 @@ function renderCodeModeSummary(
279
274
  : undefined,
280
275
  details.result === "failed" ? theme.fg("muted", details.error.code) : undefined,
281
276
  details.result === "failed" ? boundedCodeModePreview(details.error.message, 64) : undefined,
277
+ consoleEntries === undefined
278
+ ? undefined
279
+ : theme.fg("muted", pluralizedCodeModeCount(consoleEntries.length, "console call")),
282
280
  state !== "running" && presentation !== undefined && presentation.nested_tool_count > 0
283
281
  ? theme.fg("muted", pluralizedCodeModeCount(presentation.nested_tool_count, "tool"))
284
282
  : undefined,
@@ -414,6 +412,7 @@ export function renderCodeModeToolResult(
414
412
  return renderCodeModeFallback(result, options, theme, isError);
415
413
  }
416
414
  const details = result.details;
415
+ const consoleEntries = details.result === "pending" ? undefined : details.console;
417
416
  const summary = renderCodeModeSummary(details, toolName, theme, formatSessionPrefix);
418
417
  if (!options.expanded) {
419
418
  const hint = options.isPartial ? "" : ` · ${keyText("app.tools.expand")} to expand`;
@@ -462,6 +461,13 @@ export function renderCodeModeToolResult(
462
461
  }
463
462
  }
464
463
 
464
+ if (consoleEntries !== undefined) {
465
+ container.addChild(new Spacer(1));
466
+ container.addChild(new Text(theme.fg("muted", theme.bold("Console")), 0, 0));
467
+ const output = consoleEntries.map((entry) => `${entry.method}: ${entry.text}`).join("\n");
468
+ container.addChild(new Text(theme.fg("toolOutput", boundedCodeModeText(output, 2_000)), 0, 0));
469
+ }
470
+
465
471
  if (details.result === "success") {
466
472
  container.addChild(new Spacer(1));
467
473
  container.addChild(new Text(theme.fg("muted", theme.bold("Result")), 0, 0));
@@ -1,4 +1,5 @@
1
1
  import { Buffer } from "node:buffer";
2
+ import { CODEMODE_CONSOLE_METHODS, type CodeModeConsoleEntry } from "./codemode-console-output.ts";
2
3
 
3
4
  /** Maximum UTF-8 bytes in one CodeMode worker protocol line, excluding its newline. */
4
5
  export const CODEMODE_WORKER_MESSAGE_LIMIT_BYTES = 8 * 1024 * 1024;
@@ -76,6 +77,7 @@ export type CodeModeWorkerResponse =
76
77
  readonly sessionId: string;
77
78
  readonly cellId: string;
78
79
  readonly resultJson?: string;
80
+ readonly console?: readonly CodeModeConsoleEntry[];
79
81
  }
80
82
  | {
81
83
  readonly version: 1;
@@ -83,6 +85,7 @@ export type CodeModeWorkerResponse =
83
85
  readonly sessionId: string;
84
86
  readonly cellId: string;
85
87
  readonly error: { readonly code: CodeModeWorkerCellErrorCode; readonly message: string };
88
+ readonly console?: readonly CodeModeConsoleEntry[];
86
89
  }
87
90
  | {
88
91
  readonly version: 1;
@@ -132,19 +135,61 @@ function hasString(values: readonly string[], candidate: string): boolean {
132
135
  function hasExactKeys(value: CodeModeProtocolObject, expected: readonly string[]): boolean {
133
136
  const keys = objectKeys(value);
134
137
  if (keys.length !== expected.length) return false;
135
- for (let expectedIndex = 0; expectedIndex < expected.length; expectedIndex += 1) {
136
- const expectedKey = expected[expectedIndex];
137
- if (expectedKey === undefined) return false;
138
- let found = false;
139
- for (let keyIndex = 0; keyIndex < keys.length; keyIndex += 1) {
140
- if (keys[keyIndex] === expectedKey) {
141
- found = true;
142
- break;
138
+ return expected.every((expectedKey) => expectedKey !== undefined && keys.includes(expectedKey));
139
+ }
140
+
141
+ function hasDuplicateJsonObjectKeys(message: string): boolean {
142
+ const objectKeysByDepth: (string[] | undefined)[] = [];
143
+ for (let index = 0; index < message.length; index += 1) {
144
+ const character = message[index];
145
+ if (character === "{") {
146
+ objectKeysByDepth.push([]);
147
+ continue;
148
+ }
149
+ if (character === "[") {
150
+ objectKeysByDepth.push(undefined);
151
+ continue;
152
+ }
153
+ if (character === "}" || character === "]") {
154
+ objectKeysByDepth.pop();
155
+ continue;
156
+ }
157
+ if (character !== '"') continue;
158
+
159
+ let endIndex = index + 1;
160
+ for (; endIndex < message.length; endIndex += 1) {
161
+ if (message[endIndex] === "\\") {
162
+ endIndex += 1;
163
+ continue;
143
164
  }
165
+ if (message[endIndex] === '"') break;
144
166
  }
145
- if (!found) return false;
167
+ if (endIndex >= message.length) return false;
168
+ let nextIndex = endIndex + 1;
169
+ while (
170
+ message[nextIndex] === " " ||
171
+ message[nextIndex] === "\n" ||
172
+ message[nextIndex] === "\r" ||
173
+ message[nextIndex] === "\t"
174
+ ) {
175
+ nextIndex += 1;
176
+ }
177
+ const keys = objectKeysByDepth[objectKeysByDepth.length - 1];
178
+ if (message[nextIndex] === ":" && keys !== undefined) {
179
+ try {
180
+ // SAFETY: This slice is one syntactically bounded JSON string token; the string refinement below rejects every other JSON value.
181
+ const key = jsonParse(message.slice(index, endIndex + 1)) as CodeModeProtocolValue;
182
+ if (isString(key)) {
183
+ if (hasString(keys, key)) return true;
184
+ keys.push(key);
185
+ }
186
+ } catch {
187
+ return false;
188
+ }
189
+ }
190
+ index = endIndex;
146
191
  }
147
- return true;
192
+ return false;
148
193
  }
149
194
 
150
195
  function parseProtocolJson(
@@ -158,6 +203,9 @@ function parseProtocolJson(
158
203
  if (Buffer.byteLength(message, "utf8") > CODEMODE_WORKER_MESSAGE_LIMIT_BYTES) {
159
204
  return { ok: false, message: `CodeMode worker ${subject} exceeds 8 MiB` };
160
205
  }
206
+ if (hasDuplicateJsonObjectKeys(message)) {
207
+ return { ok: false, message: `CodeMode worker ${subject} contains duplicate object keys` };
208
+ }
161
209
  try {
162
210
  // SAFETY: Successful JSON.parse output is exactly the recursive JSON representation modeled by CodeModeProtocolValue.
163
211
  return { ok: true, value: jsonParse(message) as CodeModeProtocolValue };
@@ -308,6 +356,30 @@ function parseWorkerError(
308
356
  return { code: value.code, message: value.message };
309
357
  }
310
358
 
359
+ function parseConsoleOutput(
360
+ value: CodeModeProtocolSlot,
361
+ ): readonly CodeModeConsoleEntry[] | undefined {
362
+ if (!arrayIsArray(value) || value.length === 0) return undefined;
363
+ const entries: CodeModeConsoleEntry[] = [];
364
+ for (const candidate of value) {
365
+ if (
366
+ !isRecord(candidate) ||
367
+ !hasExactKeys(candidate, ["method", "text"]) ||
368
+ !isString(candidate.method) ||
369
+ !hasString(CODEMODE_CONSOLE_METHODS, candidate.method) ||
370
+ !isString(candidate.text)
371
+ ) {
372
+ return undefined;
373
+ }
374
+ entries.push({
375
+ // SAFETY: The literal-membership check above refines the protocol string to a supported Console method.
376
+ method: candidate.method as CodeModeConsoleEntry["method"],
377
+ text: candidate.text,
378
+ });
379
+ }
380
+ return entries;
381
+ }
382
+
311
383
  function parseToolBatchResponse(
312
384
  decoded: CodeModeProtocolObject,
313
385
  ): CodeModeWorkerResponse | undefined {
@@ -401,43 +473,58 @@ export function parseCodeModeWorkerResponse(
401
473
  }
402
474
  if (
403
475
  decoded.type === "cell-result" &&
404
- hasExactKeys(
405
- decoded,
406
- decoded.resultJson === undefined
407
- ? ["cellId", "sessionId", "type", "version"]
408
- : ["cellId", "resultJson", "sessionId", "type", "version"],
409
- ) &&
410
476
  isNonEmptyString(decoded.cellId) &&
411
477
  (decoded.resultJson === undefined || isString(decoded.resultJson))
412
478
  ) {
479
+ const expectedKeys = ["cellId", "sessionId", "type", "version"];
480
+ if (decoded.resultJson !== undefined) expectedKeys.push("resultJson");
481
+ if (decoded.console !== undefined) expectedKeys.push("console");
482
+ if (!hasExactKeys(decoded, expectedKeys)) {
483
+ return { ok: false, message: "CodeMode worker response has an invalid protocol shape" };
484
+ }
485
+ const consoleEntries =
486
+ decoded.console === undefined ? undefined : parseConsoleOutput(decoded.console);
487
+ if (decoded.console !== undefined && consoleEntries === undefined) {
488
+ return { ok: false, message: "CodeMode worker response has an invalid protocol shape" };
489
+ }
413
490
  const value = {
414
491
  version: CODEMODE_WORKER_PROTOCOL_VERSION,
415
492
  type: "cell-result",
416
493
  sessionId: decoded.sessionId,
417
494
  cellId: decoded.cellId,
418
495
  } as const;
419
- return decoded.resultJson === undefined
420
- ? { ok: true, value }
421
- : { ok: true, value: { ...value, resultJson: decoded.resultJson } };
496
+ const resultValue =
497
+ decoded.resultJson === undefined ? value : { ...value, resultJson: decoded.resultJson };
498
+ return consoleEntries === undefined
499
+ ? { ok: true, value: resultValue }
500
+ : { ok: true, value: { ...resultValue, console: consoleEntries } };
422
501
  }
423
- if (
424
- decoded.type === "cell-error" &&
425
- hasExactKeys(decoded, ["cellId", "error", "sessionId", "type", "version"]) &&
426
- isNonEmptyString(decoded.cellId)
427
- ) {
502
+ if (decoded.type === "cell-error" && isNonEmptyString(decoded.cellId)) {
503
+ const expectedKeys = ["cellId", "error", "sessionId", "type", "version"];
504
+ if (decoded.console !== undefined) expectedKeys.push("console");
505
+ if (!hasExactKeys(decoded, expectedKeys)) {
506
+ return { ok: false, message: "CodeMode worker response has an invalid protocol shape" };
507
+ }
428
508
  const error = parseWorkerError(decoded.error);
429
- if (error !== undefined && ["script", "serialization", "runtime"].includes(error.code)) {
509
+ const consoleEntries =
510
+ decoded.console === undefined ? undefined : parseConsoleOutput(decoded.console);
511
+ if (
512
+ error !== undefined &&
513
+ ["script", "serialization", "runtime"].includes(error.code) &&
514
+ (decoded.console === undefined || consoleEntries !== undefined)
515
+ ) {
430
516
  // SAFETY: The literal-membership check above refines the protocol string to the closed worker error code union.
431
517
  const code = error.code as CodeModeWorkerCellErrorCode;
518
+ const value = {
519
+ version: CODEMODE_WORKER_PROTOCOL_VERSION,
520
+ type: "cell-error",
521
+ sessionId: decoded.sessionId,
522
+ cellId: decoded.cellId,
523
+ error: { code, message: error.message },
524
+ } as const;
432
525
  return {
433
526
  ok: true,
434
- value: {
435
- version: CODEMODE_WORKER_PROTOCOL_VERSION,
436
- type: "cell-error",
437
- sessionId: decoded.sessionId,
438
- cellId: decoded.cellId,
439
- error: { code, message: error.message },
440
- },
527
+ value: consoleEntries === undefined ? value : { ...value, console: consoleEntries },
441
528
  };
442
529
  }
443
530
  }
@@ -456,7 +543,28 @@ export function serializeCodeModeWorkerRequest(
456
543
 
457
544
  /** Serializes one worker response, replacing oversized Cell output with a bounded error. */
458
545
  export function serializeCodeModeWorkerResponse(response: CodeModeWorkerResponse): string {
459
- const message = jsonStringify(response);
546
+ let message: string;
547
+ if (response.type === "cell-result" && response.console?.length === 0) {
548
+ const responseBase = {
549
+ version: response.version,
550
+ type: response.type,
551
+ sessionId: response.sessionId,
552
+ cellId: response.cellId,
553
+ } as const;
554
+ message = jsonStringify(
555
+ response.resultJson === undefined
556
+ ? responseBase
557
+ : { ...responseBase, resultJson: response.resultJson },
558
+ );
559
+ } else if (response.type === "cell-error" && response.console?.length === 0) {
560
+ message = jsonStringify({
561
+ version: response.version,
562
+ type: response.type,
563
+ sessionId: response.sessionId,
564
+ cellId: response.cellId,
565
+ error: response.error,
566
+ });
567
+ } else message = jsonStringify(response);
460
568
  if (Buffer.byteLength(message, "utf8") <= CODEMODE_WORKER_MESSAGE_LIMIT_BYTES) return message;
461
569
  if (
462
570
  response.type === "cell-result" ||