@gajae-code/coding-agent 0.12.12 → 0.12.14

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 (50) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/dist/types/cli/marketplace-hint.d.ts +28 -0
  3. package/dist/types/cli.d.ts +10 -0
  4. package/dist/types/config/file-lock-gc.d.ts +13 -1
  5. package/dist/types/config/model-registry.d.ts +6 -0
  6. package/dist/types/config/models-config-schema.d.ts +99 -0
  7. package/dist/types/gjc-runtime/deep-interview-state.d.ts +14 -0
  8. package/dist/types/gjc-runtime/gc-runtime.d.ts +11 -0
  9. package/dist/types/modes/acp/acp-agent.d.ts +24 -1
  10. package/dist/types/sdk/bus/html-format.d.ts +10 -0
  11. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +6 -2
  12. package/dist/types/sdk/bus/telegram-daemon.d.ts +15 -1
  13. package/dist/types/sdk/bus/telegram-reference.d.ts +10 -0
  14. package/dist/types/session/agent-session.d.ts +0 -2
  15. package/dist/types/session/session-manager.d.ts +17 -1
  16. package/dist/types/tools/index.d.ts +7 -0
  17. package/package.json +8 -8
  18. package/src/cli/fast-help.ts +1 -0
  19. package/src/cli/marketplace-hint.ts +39 -0
  20. package/src/cli/plugin-cli.ts +10 -0
  21. package/src/cli.ts +23 -0
  22. package/src/config/file-lock-gc.ts +47 -26
  23. package/src/config/model-profiles.ts +18 -18
  24. package/src/config/model-registry.ts +17 -5
  25. package/src/config/models-config-schema.ts +9 -4
  26. package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
  27. package/src/gjc-runtime/deep-interview-recorder.ts +9 -5
  28. package/src/gjc-runtime/deep-interview-state.ts +27 -3
  29. package/src/gjc-runtime/gc-render.ts +8 -1
  30. package/src/gjc-runtime/gc-runtime.ts +15 -1
  31. package/src/internal-urls/docs-index.generated.ts +4 -4
  32. package/src/main.ts +11 -3
  33. package/src/modes/acp/acp-agent.ts +123 -7
  34. package/src/modes/components/model-selector.ts +52 -5
  35. package/src/modes/print-mode.ts +10 -0
  36. package/src/prompts/system/project-prompt.md +19 -0
  37. package/src/sdk/bus/html-format.ts +19 -3
  38. package/src/sdk/bus/index.ts +21 -12
  39. package/src/sdk/bus/telegram-daemon-contract.ts +6 -2
  40. package/src/sdk/bus/telegram-daemon.ts +50 -16
  41. package/src/sdk/bus/telegram-reference.ts +72 -9
  42. package/src/sdk/session.ts +5 -1
  43. package/src/session/agent-session.ts +0 -4
  44. package/src/session/internal/managed-session-storage.ts +24 -2
  45. package/src/session/session-manager.ts +77 -22
  46. package/src/setup/provider-presets.json +3 -3
  47. package/src/thinking-metadata.ts +2 -1
  48. package/src/tools/ask.ts +10 -0
  49. package/src/tools/index.ts +7 -0
  50. package/src/tools/todo-write.ts +4 -4
@@ -16,11 +16,13 @@
16
16
 
17
17
  import * as crypto from "node:crypto";
18
18
  import * as fs from "node:fs";
19
+ import { logger } from "@gajae-code/utils";
19
20
  import {
20
21
  bold,
21
22
  buildCompactChoiceGrid,
22
23
  escapeHtml,
23
24
  numberedOptionList,
25
+ renumberableOptionText,
24
26
  splitTelegramHtml,
25
27
  TELEGRAM_PARSE_MODE,
26
28
  } from "./html-format";
@@ -34,6 +36,44 @@ const REFERENCE_CLIENT_HELLO = {
34
36
  capabilities: ["ask_controls_v1"],
35
37
  } as const;
36
38
 
39
+ const MAX_DIAGNOSTIC_CAPABILITIES = 4;
40
+ const MAX_DIAGNOSTIC_CAPABILITY_CODE_POINTS = 64;
41
+ const MAX_DIAGNOSTIC_ERROR_NAME_CODE_POINTS = 64;
42
+ const MAX_DIAGNOSTIC_ERROR_MESSAGE_CODE_POINTS = 128;
43
+
44
+ function boundedDiagnosticText(value: string, maxCodePoints: number): string {
45
+ let result = "";
46
+ let codePoints = 0;
47
+ for (const character of value) {
48
+ const codePoint = character.codePointAt(0)!;
49
+ if (codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f)) continue;
50
+ result += character;
51
+ codePoints++;
52
+ if (codePoints === maxCodePoints) break;
53
+ }
54
+ return result;
55
+ }
56
+
57
+ function boundedDiagnosticCallbackError(error: unknown): Readonly<{ errorName: string; errorMessage: string }> {
58
+ let errorName = "UnknownError";
59
+ let errorMessage = "Diagnostic callback threw";
60
+ try {
61
+ if (error instanceof Error) {
62
+ errorName = boundedDiagnosticText(error.name, MAX_DIAGNOSTIC_ERROR_NAME_CODE_POINTS) || "Error";
63
+ errorMessage =
64
+ boundedDiagnosticText(error.message, MAX_DIAGNOSTIC_ERROR_MESSAGE_CODE_POINTS) ||
65
+ "Diagnostic callback threw";
66
+ } else {
67
+ errorMessage =
68
+ boundedDiagnosticText(String(error), MAX_DIAGNOSTIC_ERROR_MESSAGE_CODE_POINTS) ||
69
+ "Diagnostic callback threw";
70
+ }
71
+ } catch {
72
+ // Hostile thrown values must not escape the diagnostic failure boundary.
73
+ }
74
+ return Object.freeze({ errorName, errorMessage });
75
+ }
76
+
37
77
  /** One inline-keyboard button. */
38
78
  export interface InlineButton {
39
79
  text: string;
@@ -254,7 +294,7 @@ export function buildActionMarkdown(action: {
254
294
  const options = action.options ?? [];
255
295
  const displayOptions = withRecommendedOptionLabel(options, action.recommendedIndex);
256
296
  if (options.length === 0) return `${heading}\n\n(reply with text)`;
257
- const list = displayOptions.map((label, i) => `${i + 1}. ${label.replace(/^\s*\d+[.)]\s+/, "")}`).join("\n");
297
+ const list = displayOptions.map((label, i) => `${i + 1}. ${renumberableOptionText(label)}`).join("\n");
258
298
  return `${heading}\n\n${list}`;
259
299
  }
260
300
 
@@ -406,6 +446,12 @@ export function readEndpoint(path: string): { url: string; token: string; pid?:
406
446
  };
407
447
  }
408
448
 
449
+ /** A bounded, diagnostic-only notification mirrored to reference-client embedders. */
450
+ export type TelegramReferenceDiagnostic = Readonly<{
451
+ code: "action_unavailable";
452
+ requiredCapabilities: readonly string[];
453
+ }>;
454
+
409
455
  /** Options for {@link runTelegramReferenceClient}. */
410
456
  export interface TelegramReferenceOptions {
411
457
  botToken: string;
@@ -414,6 +460,11 @@ export interface TelegramReferenceOptions {
414
460
  apiBase?: string;
415
461
  fetchImpl?: typeof fetch;
416
462
  sound?: TelegramNotificationSound;
463
+ /**
464
+ * Additive mirror of bounded logger diagnostics. It never receives tokens,
465
+ * identifiers, raw frames, Telegram content, or WebSocket replies.
466
+ */
467
+ onDiagnostic?: (diagnostic: TelegramReferenceDiagnostic) => void;
417
468
  }
418
469
 
419
470
  /**
@@ -470,15 +521,27 @@ export async function runTelegramReferenceClient(opts: TelegramReferenceOptions)
470
521
  msg.kind ?? "ask",
471
522
  );
472
523
  } else if (msg.type === "action_unavailable") {
473
- const requiredCapabilities = Array.isArray(msg.requiredCapabilities)
474
- ? msg.requiredCapabilities
475
- .filter((capability): capability is string => typeof capability === "string")
476
- .slice(0, 4)
477
- .map(capability => capability.slice(0, 64))
478
- : [];
479
- console.warn(
480
- `Telegram reference client: server withheld a controlled ask because this client lacks requiredCapabilities=[${requiredCapabilities.join(", ") || "unspecified"}].`,
524
+ const requiredCapabilities = Object.freeze(
525
+ Array.isArray(msg.requiredCapabilities)
526
+ ? msg.requiredCapabilities
527
+ .filter((capability): capability is string => typeof capability === "string")
528
+ .slice(0, MAX_DIAGNOSTIC_CAPABILITIES)
529
+ .map(capability => boundedDiagnosticText(capability, MAX_DIAGNOSTIC_CAPABILITY_CODE_POINTS))
530
+ : [],
481
531
  );
532
+ const diagnostic: TelegramReferenceDiagnostic = Object.freeze({
533
+ code: "action_unavailable",
534
+ requiredCapabilities,
535
+ });
536
+ logger.warn("Telegram reference client action unavailable", diagnostic);
537
+ try {
538
+ opts.onDiagnostic?.(diagnostic);
539
+ } catch (error) {
540
+ logger.warn("Telegram reference client diagnostic callback failed", {
541
+ code: "diagnostic_callback_failed",
542
+ ...boundedDiagnosticCallbackError(error),
543
+ });
544
+ }
482
545
  // Diagnostic only: never turn it into a Telegram prompt or option buttons.
483
546
  return;
484
547
  } else if (msg.type === "action_resolved" && msg.id === latestPendingAskId) {
@@ -1253,7 +1253,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1253
1253
  return SessionManager.create(cwd, SessionManager.managedDestination(cwd, agentDir));
1254
1254
  }));
1255
1255
  const logicalSessionId = sessionManager.getSessionId();
1256
- const providerSessionId = options.providerSessionId ?? options.forkContextSeed?.cacheIdentity ?? logicalSessionId;
1256
+ // Fork-context seeds carry conversation content only, never provider identity:
1257
+ // a shared continuity id would make concurrent subagents present the same
1258
+ // session_id upstream, where session-owning transports reject the extra
1259
+ // downstreams (owner_busy) and degrade those turns to uncached HTTP.
1260
+ const providerSessionId = options.providerSessionId ?? logicalSessionId;
1257
1261
  const modelApiKeyAvailability = new Map<string, boolean>();
1258
1262
  const getModelAvailabilityKey = (candidate: Model): string =>
1259
1263
  `${candidate.provider}\u0000${candidate.baseUrl ?? ""}`;
@@ -159,7 +159,6 @@ export interface ForkContextSeed {
159
159
  messages: Message[];
160
160
  agentMessages: AgentMessage[];
161
161
  metadata: ForkContextSeedMetadata;
162
- cacheIdentity?: string;
163
162
  appendOnlyPrefixSnapshot?: StablePrefixSnapshot;
164
163
  }
165
164
 
@@ -167,7 +166,6 @@ export interface ForkContextSeedOptions {
167
166
  maxMessages: number;
168
167
  maxTokens: number;
169
168
  preserveLatestUser?: boolean;
170
- cacheIdentity?: string;
171
169
  signal?: AbortSignal;
172
170
  }
173
171
 
@@ -3118,7 +3116,6 @@ export class AgentSession {
3118
3116
  maxTokens,
3119
3117
  skippedReasons: {},
3120
3118
  },
3121
- cacheIdentity: options.cacheIdentity ?? this.sessionId,
3122
3119
  };
3123
3120
  }
3124
3121
  const transformedMessages = await this.#transformContext([...this.messages], options.signal);
@@ -3318,7 +3315,6 @@ export class AgentSession {
3318
3315
  maxTokens,
3319
3316
  skippedReasons,
3320
3317
  },
3321
- cacheIdentity: options.cacheIdentity ?? this.sessionId,
3322
3318
  appendOnlyPrefixSnapshot,
3323
3319
  };
3324
3320
  }
@@ -943,6 +943,13 @@ export class ManagedSessionDescendantStore {
943
943
  detachOnly: true,
944
944
  quarantineName,
945
945
  });
946
+ if (
947
+ !detached.ok &&
948
+ detached.code === "not_found" &&
949
+ detached.retainedSuccessorPath === undefined &&
950
+ detached.retainedUnknownPath === undefined
951
+ )
952
+ return;
946
953
  if (
947
954
  (!detached.ok && detached.code !== "cleanup_pending") ||
948
955
  detached.detachedPath !== path.join(this.#baseDir, quarantineName) ||
@@ -956,7 +963,13 @@ export class ManagedSessionDescendantStore {
956
963
  #reconcileLegacyReplacementCleanupReceipt(receiptPath: string, name: string): void {
957
964
  const binding = legacyReplacementCleanupReceiptBinding(name);
958
965
  if (!binding) throw new Error("managed_replace_cleanup_receipt_invalid");
959
- const receipt = captureManagedFileNoFollow(receiptPath);
966
+ let receipt: ManagedFileSnapshot;
967
+ try {
968
+ receipt = captureManagedFileNoFollow(receiptPath);
969
+ } catch (error) {
970
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
971
+ throw error;
972
+ }
960
973
  const parsed = parseLegacyReplacementCleanupReceipt(receipt.bytes);
961
974
  const expectedPredecessor = path.join(
962
975
  this.#baseDir,
@@ -985,7 +998,16 @@ export class ManagedSessionDescendantStore {
985
998
  if (!exactUnlinkCompleted(retired) && retired.code !== "not_found")
986
999
  throw new Error(`managed_replace_cleanup_pending:${retired.code ?? "unknown"}`);
987
1000
 
988
- const currentReceipt = captureManagedFileNoFollow(receiptPath);
1001
+ let currentReceipt: ManagedFileSnapshot;
1002
+ try {
1003
+ currentReceipt = captureManagedFileNoFollow(receiptPath);
1004
+ } catch (error) {
1005
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
1006
+ fsyncDirectory(this.#baseDir);
1007
+ return;
1008
+ }
1009
+ throw error;
1010
+ }
989
1011
  if (
990
1012
  !sameIdentity(currentReceipt.identity, receipt.identity) ||
991
1013
  currentReceipt.identity.sha256 !== receipt.identity.sha256
@@ -76,6 +76,7 @@ import {
76
76
  assertManagedDirectoryRoot,
77
77
  captureManagedFileNoFollow,
78
78
  fsyncManagedArtifactTree,
79
+ MANAGED_ARTIFACT_MAX_FILE_BYTES,
79
80
  type ManagedDirectoryRoot,
80
81
  type ManagedFileSnapshot,
81
82
  ManagedSessionDescendantStore,
@@ -915,12 +916,25 @@ export interface ResumeTailTerminal {
915
916
 
916
917
  export interface ResumeTailError {
917
918
  kind: "error";
918
- reason: "missing" | "malformed" | "unstable" | "read-failed" | "legacy_migration_disabled";
919
+ reason: "missing" | "malformed" | "unstable" | "read-failed" | "legacy_migration_disabled" | "oversized";
920
+ size?: number;
919
921
  }
920
922
 
921
923
  export type SessionDirectoryMigrationPolicy = ManagedMigrationPolicy;
922
924
  export type SessionAppendPersistenceFailurePhase = "current_append" | "prior_failure";
923
925
 
926
+ /**
927
+ * Safety bound for resuming a single transcript file. Matches the managed-storage
928
+ * per-file artifact limit (`MANAGED_ARTIFACT_MAX_FILE_BYTES`): transcripts at or above
929
+ * this size are not loaded into memory wholesale because the decode + per-line JSON
930
+ * parse + identity hash would OOM or stall the process (#3851). The bound is a
931
+ * load-time guard only; it does not raise the on-disk managed-storage limit.
932
+ */
933
+ export const RESUME_TRANSCRIPT_MAX_BYTES = MANAGED_ARTIFACT_MAX_FILE_BYTES;
934
+
935
+ export const SESSION_OVERSIZED_RECOVERY_MESSAGE =
936
+ "The selected session transcript is too large to resume safely. Use `gjc export <session-file>` to export its content into a new session, or remove/archive it after confirming its content is no longer needed.";
937
+
924
938
  export class SessionAppendPersistenceError extends Error {
925
939
  readonly phase: SessionAppendPersistenceFailurePhase;
926
940
  readonly entryId: string;
@@ -964,6 +978,17 @@ export class SessionArtifactCapacityError extends Error {
964
978
  }
965
979
  }
966
980
 
981
+ export class SessionTranscriptOversizedError extends Error {
982
+ readonly code = "oversized";
983
+ readonly size: number;
984
+
985
+ constructor(size: number) {
986
+ super(SESSION_OVERSIZED_RECOVERY_MESSAGE);
987
+ this.name = "SessionTranscriptOversizedError";
988
+ this.size = size;
989
+ }
990
+ }
991
+
967
992
  export type ResumeTailInspection = ResumeTailResumable | ResumeTailTerminal | ResumeTailError;
968
993
  export interface StrictSessionOpenSuccess {
969
994
  kind: "opened";
@@ -979,6 +1004,7 @@ export interface StrictSessionOpenFailure {
979
1004
  | "artifact_capacity_exceeded"
980
1005
  | "migration_busy";
981
1006
  message?: string;
1007
+ size?: number;
982
1008
  }
983
1009
 
984
1010
  /** Exact transcript bytes and authority captured from one descriptor-bound read. */
@@ -2346,6 +2372,12 @@ function inspectResumeSessionFile(
2346
2372
  if (!before.isFile || !storage.readSnapshotSync) {
2347
2373
  return { kind: "error", reason: "read-failed" };
2348
2374
  }
2375
+ // Fail closed on oversized transcripts before the full read/decode/parse
2376
+ // path that would otherwise OOM or stall the process (#3851). The stat is
2377
+ // already in hand; this bound matches the managed-storage per-file limit.
2378
+ if (before.size > RESUME_TRANSCRIPT_MAX_BYTES) {
2379
+ return { kind: "error", reason: "oversized", size: before.size };
2380
+ }
2349
2381
 
2350
2382
  let bytes: Uint8Array;
2351
2383
  let snapshot: SessionStorageStat;
@@ -4378,12 +4410,6 @@ function extractTextFromContent(content: Message["content"]): string {
4378
4410
 
4379
4411
  const SESSION_LIST_PREFIX_BYTES = 4096;
4380
4412
  const SESSION_LIST_TRAILING_PATCH_BYTES = 4096;
4381
- // Hard cap on how many trailing bytes the listers may scan for header patches.
4382
- // moveTo folds cwd patches into line 1 via a full atomic rewrite, so only a
4383
- // recent title patch can live unfolded near EOF; anything buried deeper is
4384
- // folded on the next natural full rewrite. Keeping this bounded preserves the
4385
- // O(SESSION_LIST_PREFIX_BYTES) listing cost that SESSION_LIST_PREFIX_BYTES exists for.
4386
- const SESSION_LIST_TRAILING_PATCH_SCAN_CAP = SESSION_LIST_TRAILING_PATCH_BYTES * 4;
4387
4413
  const SESSION_NAME_MAX_CHARS = 1_000;
4388
4414
  const SESSION_LIST_PARALLEL_THRESHOLD = 64;
4389
4415
  const SESSION_LIST_MAX_WORKERS = 16;
@@ -4419,12 +4445,17 @@ async function readSessionListTrailingPatches(
4419
4445
  if (size <= SESSION_LIST_PREFIX_BYTES) return [];
4420
4446
  const latest: HeaderPatchRecord["patch"] = {};
4421
4447
  let position = size;
4422
- const scanFloor = Math.max(0, size - SESSION_LIST_TRAILING_PATCH_SCAN_CAP);
4448
+ // Reverse-scan from EOF in fixed chunks. Stop once both cwd and title
4449
+ // resolve so recent patches stay cheap. Continue past the historical 16 KiB
4450
+ // window when a field is still missing so a buried but canonically valid
4451
+ // header_patch remains listable without a full sequential JSONL parse (#3633).
4452
+ // Chunks that cannot contain a header_patch marker skip JSON parsing.
4423
4453
  let trailingFragment = Buffer.alloc(0);
4424
4454
  const chunkSize = Math.min(buffer.byteLength, SESSION_LIST_TRAILING_PATCH_BYTES);
4455
+ const headerPatchMarker = Buffer.from("header_patch");
4425
4456
  const handle = await fs.promises.open(file, "r");
4426
4457
  try {
4427
- while (position > scanFloor && (latest.cwd === undefined || latest.title === undefined)) {
4458
+ while (position > 0 && (latest.cwd === undefined || latest.title === undefined)) {
4428
4459
  const start = Math.max(0, position - chunkSize);
4429
4460
  const length = position - start;
4430
4461
  const { bytesRead } = await handle.read(buffer, 0, length, start);
@@ -4440,18 +4471,26 @@ async function readSessionListTrailingPatches(
4440
4471
  trailingFragment = combined.subarray(0, firstNewline);
4441
4472
  complete = combined.subarray(firstNewline + 1);
4442
4473
  }
4443
- const lines = complete.toString("utf8").split("\n");
4444
- for (let index = lines.length - 1; index >= 0; index--) {
4445
- const line = lines[index];
4446
- if (!line) continue;
4447
- try {
4448
- const record = JSON.parse(line) as SessionPatchRecord;
4449
- if (!isHeaderPatchRecord(record)) continue;
4450
- if (latest.cwd === undefined && typeof record.patch.cwd === "string") latest.cwd = record.patch.cwd;
4451
- if (latest.title === undefined && typeof record.patch.title === "string")
4452
- latest.title = record.patch.title;
4453
- } catch {
4454
- // Ignore malformed or partial records exactly as the canonical loader does.
4474
+ // Marker may straddle the discarded partial first line; still parse when
4475
+ // the raw chunk or carried fragment could hold a header_patch record.
4476
+ const mayContainPatch =
4477
+ complete.includes(headerPatchMarker) ||
4478
+ buffer.subarray(0, bytesRead).includes(headerPatchMarker) ||
4479
+ trailingFragment.includes(headerPatchMarker);
4480
+ if (mayContainPatch) {
4481
+ const lines = complete.toString("utf8").split("\n");
4482
+ for (let index = lines.length - 1; index >= 0; index--) {
4483
+ const line = lines[index];
4484
+ if (!line?.includes("header_patch")) continue;
4485
+ try {
4486
+ const record = JSON.parse(line) as SessionPatchRecord;
4487
+ if (!isHeaderPatchRecord(record)) continue;
4488
+ if (latest.cwd === undefined && typeof record.patch.cwd === "string") latest.cwd = record.patch.cwd;
4489
+ if (latest.title === undefined && typeof record.patch.title === "string")
4490
+ latest.title = record.patch.title;
4491
+ } catch {
4492
+ // Ignore malformed or partial records exactly as the canonical loader does.
4493
+ }
4455
4494
  }
4456
4495
  }
4457
4496
  position = start;
@@ -9938,6 +9977,16 @@ export class SessionManager {
9938
9977
  // owner-only and reparse guards accept.
9939
9978
  if (storage instanceof FileSessionStorage) filePath = canonicalizeTrustedPath(filePath);
9940
9979
  if (destination.kind === "explicit" || !(storage instanceof FileSessionStorage)) {
9980
+ // Fail closed on oversized transcripts before the full read in the explicit path too (#3851).
9981
+ // A missing file falls through to loadEntriesFromFile, which returns [] and creates
9982
+ // a fresh session — matching the pre-existing behavior.
9983
+ try {
9984
+ const preStat = storage.statSync(filePath);
9985
+ if (preStat.size > RESUME_TRANSCRIPT_MAX_BYTES) throw new SessionTranscriptOversizedError(preStat.size);
9986
+ } catch (error) {
9987
+ if (error instanceof SessionTranscriptOversizedError) throw error;
9988
+ if (!isEnoent(error)) throw error;
9989
+ }
9941
9990
  const entries = await loadEntriesFromFile(filePath, storage);
9942
9991
  const header = entries.find(entry => entry.type === "session") as SessionHeader | undefined;
9943
9992
  const manager = new SessionManager(
@@ -9958,6 +10007,7 @@ export class SessionManager {
9958
10007
  await manager.#initSessionFile(filePath);
9959
10008
  return manager;
9960
10009
  }
10010
+ if (inspected.reason === "oversized") throw new SessionTranscriptOversizedError(inspected.size ?? 0);
9961
10011
  throw new Error(`Could not open session: ${inspected.reason}`);
9962
10012
  }
9963
10013
  const opened = await SessionManager.openExistingStrict(inspected.identity, destination, storage, migrationPolicy);
@@ -9967,6 +10017,7 @@ export class SessionManager {
9967
10017
  throw new SessionArtifactCapacityError(
9968
10018
  opened.message ?? "Session artifacts exceed the migration capacity.",
9969
10019
  );
10020
+ if (opened.reason === "oversized") throw new SessionTranscriptOversizedError(opened.size ?? 0);
9970
10021
  if (opened.reason === "migration_busy") throw new SessionMigrationBusyError();
9971
10022
  throw new Error(`Could not open session: ${opened.reason}`);
9972
10023
  }
@@ -10705,7 +10756,10 @@ export class SessionManager {
10705
10756
  const dir = destination.directory;
10706
10757
  const openSelectedStrictly = async (selectedPath: string): Promise<SessionManager | undefined> => {
10707
10758
  const inspected = await SessionManager.inspectSessionTailReadOnly(selectedPath, storage);
10708
- if (inspected.kind === "error") return undefined;
10759
+ if (inspected.kind === "error") {
10760
+ if (inspected.reason === "oversized") throw new SessionTranscriptOversizedError(inspected.size ?? 0);
10761
+ return undefined;
10762
+ }
10709
10763
  const opened = await SessionManager.openExistingStrict(
10710
10764
  inspected.identity,
10711
10765
  destination,
@@ -10718,6 +10772,7 @@ export class SessionManager {
10718
10772
  throw new SessionArtifactCapacityError(
10719
10773
  opened.message ?? "Session artifacts exceed the migration capacity.",
10720
10774
  );
10775
+ if (opened.reason === "oversized") throw new SessionTranscriptOversizedError(opened.size ?? 0);
10721
10776
  if (opened.reason === "migration_busy") throw new SessionMigrationBusyError();
10722
10777
  return undefined;
10723
10778
  }
@@ -1,6 +1,6 @@
1
1
  [
2
- {"id":"minimax","aliases":["minimax-code"],"name":"MiniMax Coding Plan","description":"OpenAI-compatible MiniMax Coding Plan endpoint","compatibility":"openai","api":"openai-completions","providerId":"minimax-code","baseUrl":"https://api.minimax.io/v1","apiKeyEnv":"MINIMAX_CODE_API_KEY","models":["minimax-m3"],"compat":{"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"reasoningContentField":"reasoning_content"}},
3
- {"id":"minimax-cn","aliases":["minimax-code-cn","minimaxi"],"name":"MiniMax Coding Plan (China)","description":"OpenAI-compatible MiniMax China endpoint","compatibility":"openai","api":"openai-completions","providerId":"minimax-code-cn","baseUrl":"https://api.minimaxi.com/v1","apiKeyEnv":"MINIMAX_CODE_CN_API_KEY","models":["minimax-m3"],"compat":{"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"reasoningContentField":"reasoning_content"}},
2
+ {"id":"minimax","aliases":["minimax-code"],"name":"MiniMax Coding Plan","description":"OpenAI-compatible MiniMax Coding Plan endpoint","compatibility":"openai","api":"openai-completions","providerId":"minimax-code","baseUrl":"https://api.minimax.io/v1","apiKeyEnv":"MINIMAX_CODE_API_KEY","models":["MiniMax-M3"],"compat":{"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"reasoningContentField":"reasoning_content"}},
3
+ {"id":"minimax-cn","aliases":["minimax-code-cn","minimaxi"],"name":"MiniMax Coding Plan (China)","description":"OpenAI-compatible MiniMax China endpoint","compatibility":"openai","api":"openai-completions","providerId":"minimax-code-cn","baseUrl":"https://api.minimaxi.com/v1","apiKeyEnv":"MINIMAX_CODE_CN_API_KEY","models":["MiniMax-M3"],"compat":{"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"reasoningContentField":"reasoning_content"}},
4
4
  {"id":"glm","aliases":["zai","z-ai","bigmodel"],"name":"GLM / zAI","description":"OpenAI-compatible GLM endpoint from zAI/BigModel","compatibility":"openai","api":"openai-completions","providerId":"glm-proxy","baseUrl":"https://api.z.ai/api/paas/v4","apiKeyEnv":"ZAI_API_KEY","models":["glm-4.6"],"compat":{"supportsDeveloperRole":false,"supportsReasoningEffort":false,"thinkingFormat":"zai","reasoningContentField":"reasoning_content"}},
5
- {"id":"alibaba-token-plan","aliases":["alibaba","token-plan"],"name":"Alibaba Token Plan","description":"OpenAI-compatible Alibaba Token Plan endpoint","compatibility":"openai","api":"openai-completions","providerId":"alibaba-token-plan","baseUrl":"https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1","apiKeyEnv":"ALIBABA_TOKEN_PLAN_API_KEY","models":["qwen3.8-max-preview","qwen-3.8-max","glm-5.2","deepseek-v4-pro"],"modelApi":{"qwen3.8-max-preview":"openai-responses","qwen-3.8-max":"openai-responses","glm-5.2":"openai-completions","deepseek-v4-pro":"openai-completions"},"compat":{"supportsDeveloperRole":false}}
5
+ {"id":"alibaba-token-plan","aliases":["alibaba","token-plan"],"name":"Alibaba Token Plan","description":"OpenAI-compatible Alibaba Token Plan endpoint","compatibility":"openai","api":"openai-completions","providerId":"alibaba-token-plan","baseUrl":"https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1","apiKeyEnv":"ALIBABA_TOKEN_PLAN_API_KEY","models":["qwen3.8-max-preview","qwen3.8-max","glm-5.2","deepseek-v4-pro"],"modelApi":{"qwen3.8-max-preview":"openai-responses","qwen3.8-max":"openai-responses","glm-5.2":"openai-completions","deepseek-v4-pro":"openai-completions"},"compat":{"supportsDeveloperRole":false}}
6
6
  ]
@@ -39,7 +39,8 @@ const THINKING_LEVEL_METADATA: Record<ThinkingLevelValue, ThinkingLevelMetadata>
39
39
  max: {
40
40
  value: "max",
41
41
  label: "max",
42
- description: "Opus maximum reasoning",
42
+ // Provider-neutral: max is not Anthropic/Opus-specific (OpenAI, Luna, etc.).
43
+ description: "Absolute maximum reasoning",
43
44
  },
44
45
  };
45
46
 
package/src/tools/ask.ts CHANGED
@@ -763,6 +763,8 @@ interface AskSingleQuestionOptions {
763
763
  onRemoteState?: (state: {
764
764
  interaction: "selector" | "custom_editor" | "clarification_editor";
765
765
  selectedCount: number;
766
+ /** Currently selected option labels, so a remote transport can render the selection. */
767
+ selectedOptions: readonly string[];
766
768
  hasNonWhitespaceCustom: boolean;
767
769
  }) => void;
768
770
  }
@@ -900,6 +902,7 @@ async function askSingleQuestion(
900
902
  options.onRemoteState?.({
901
903
  interaction: "custom_editor",
902
904
  selectedCount: selectedOptions.length,
905
+ selectedOptions: [...selectedOptions],
903
906
  hasNonWhitespaceCustom: (customInput?.trim().length ?? 0) > 0,
904
907
  });
905
908
  return ui.editor("Enter your response:", undefined, dialogOptions, { promptStyle: true });
@@ -913,6 +916,7 @@ async function askSingleQuestion(
913
916
  options.onRemoteState?.({
914
917
  interaction: "clarification_editor",
915
918
  selectedCount: selectedOptions.length,
919
+ selectedOptions: [...selectedOptions],
916
920
  hasNonWhitespaceCustom: false,
917
921
  });
918
922
  return ui.editor("Ask a clarification question:", undefined, dialogOptions, { promptStyle: true });
@@ -949,6 +953,7 @@ async function askSingleQuestion(
949
953
  options.onRemoteState?.({
950
954
  interaction: "selector",
951
955
  selectedCount: selected.size,
956
+ selectedOptions: [...selected],
952
957
  hasNonWhitespaceCustom: (customInput?.trim().length ?? 0) > 0,
953
958
  });
954
959
  const prefix = selected.size > 0 ? `(${selected.size} selected) ` : "";
@@ -1054,6 +1059,7 @@ async function askSingleQuestion(
1054
1059
  options.onRemoteState?.({
1055
1060
  interaction: "selector",
1056
1061
  selectedCount: selectedOptions.length,
1062
+ selectedOptions: [...selectedOptions],
1057
1063
  hasNonWhitespaceCustom: (customInput?.trim().length ?? 0) > 0,
1058
1064
  });
1059
1065
  const {
@@ -1591,6 +1597,8 @@ export class AskTool implements AgentTool<AskParametersSchema, AskToolDetails> {
1591
1597
  options: remoteSelectorOptions,
1592
1598
  interaction: "selector",
1593
1599
  ...(recommendedIndex === undefined ? {} : { recommendedIndex }),
1600
+ multi: q.multi === true,
1601
+ selectedOptions: [...(initialSelection?.selectedOptions ?? [])],
1594
1602
  controls: askRemoteControls({
1595
1603
  multi: q.multi === true,
1596
1604
  questionIndex,
@@ -1625,6 +1633,8 @@ export class AskTool implements AgentTool<AskParametersSchema, AskToolDetails> {
1625
1633
  ...(state.interaction === "selector" && recommendedIndex !== undefined
1626
1634
  ? { recommendedIndex }
1627
1635
  : {}),
1636
+ multi: q.multi === true,
1637
+ selectedOptions: [...state.selectedOptions],
1628
1638
  controls:
1629
1639
  state.interaction === "selector"
1630
1640
  ? askRemoteControls({
@@ -139,6 +139,13 @@ export interface AskAnswerRequest {
139
139
  controls: readonly AskRemoteControl[];
140
140
  /** Optional zero-based recommendation into the authoritative raw options. */
141
141
  recommendedIndex?: number;
142
+ /** True while the question accepts several options before the navigation control commits. */
143
+ multi?: boolean;
144
+ /**
145
+ * Options currently selected in the multi-select loop, as authoritative option
146
+ * labels. Remote transports render the selection state so a toggle is visible.
147
+ */
148
+ selectedOptions?: readonly string[];
142
149
  }
143
150
 
144
151
  export type AskRemoteInteraction =
@@ -73,14 +73,14 @@ function hasUnknownKeys(value: object, allowed: Set<string>): boolean {
73
73
  }
74
74
 
75
75
  function validateRawTodoArguments(arguments_: Record<string, unknown>): RawArgumentValidationResult {
76
- if (hasUnknownKeys(arguments_, TODO_WRITE_KEYS)) return { outcome: "reject" };
76
+ if (hasUnknownKeys(arguments_, TODO_WRITE_KEYS)) return { outcome: "reject", code: "todo-write-unknown-root-key" };
77
77
  if (!Array.isArray(arguments_.ops)) return { outcome: "passthrough" };
78
78
  for (const entry of arguments_.ops) {
79
79
  if (typeof entry !== "object" || entry === null || Array.isArray(entry)) continue;
80
- if (hasUnknownKeys(entry, TODO_OP_KEYS)) return { outcome: "reject" };
80
+ if (hasUnknownKeys(entry, TODO_OP_KEYS)) return { outcome: "reject", code: "todo-write-unknown-op-entry-key" };
81
81
  const record = entry as Record<string, unknown>;
82
82
  if ((record.op === "done" || record.op === "drop") && !record.task && !record.phase) {
83
- return { outcome: "reject" };
83
+ return { outcome: "reject", code: "todo-write-done-drop-requires-target" };
84
84
  }
85
85
  const list = record.list;
86
86
  if (!Array.isArray(list)) continue;
@@ -91,7 +91,7 @@ function validateRawTodoArguments(arguments_: Record<string, unknown>): RawArgum
91
91
  !Array.isArray(item) &&
92
92
  hasUnknownKeys(item, TODO_INIT_ENTRY_KEYS)
93
93
  )
94
- return { outcome: "reject" };
94
+ return { outcome: "reject", code: "todo-write-unknown-init-entry-key" };
95
95
  }
96
96
  }
97
97
  return { outcome: "passthrough" };