@gajae-code/coding-agent 0.17.1 → 0.17.2

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 (53) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/dist/types/async/job-manager.d.ts +7 -6
  3. package/dist/types/config/autorouting-tier-map.d.ts +6 -0
  4. package/dist/types/config/file-lock.d.ts +1 -0
  5. package/dist/types/config/model-profile-activation.d.ts +18 -3
  6. package/dist/types/config/model-registry.d.ts +9 -0
  7. package/dist/types/coordinator-mcp/server.d.ts +3 -0
  8. package/dist/types/modes/acp/acp-agent.d.ts +40 -1
  9. package/dist/types/modes/components/thinking-selector.d.ts +2 -1
  10. package/dist/types/sdk/acp/adapter.d.ts +27 -0
  11. package/dist/types/sdk/acp/final-text.d.ts +8 -0
  12. package/dist/types/sdk/broker/ensure.d.ts +46 -1
  13. package/dist/types/sdk/prompt-deadline-manager.d.ts +1 -1
  14. package/dist/types/sdk/prompt-failure.d.ts +30 -1
  15. package/dist/types/sdk/prompt-status.d.ts +8 -0
  16. package/dist/types/sdk/router/replay-retention-gap-cache.d.ts +13 -0
  17. package/dist/types/sdk/transport/serve-cli.d.ts +27 -0
  18. package/dist/types/thinking.d.ts +3 -1
  19. package/dist/types/utils/mupdf-wasm-embedded.d.ts +2 -0
  20. package/package.json +7 -7
  21. package/src/async/job-manager.ts +38 -8
  22. package/src/commands/sdk.ts +78 -4
  23. package/src/config/autorouting-tier-map.ts +2 -0
  24. package/src/config/file-lock.ts +52 -17
  25. package/src/config/model-profile-activation.ts +72 -10
  26. package/src/config/model-registry.ts +21 -0
  27. package/src/coordinator-mcp/server.ts +21 -6
  28. package/src/debug/report-bundle.ts +114 -24
  29. package/src/gjc-runtime/managed-owner-admission.ts +67 -50
  30. package/src/internal-urls/docs-index.generated.ts +7 -6
  31. package/src/modes/acp/acp-agent.ts +187 -14
  32. package/src/modes/acp/acp-event-mapper.ts +9 -0
  33. package/src/modes/components/model-selector.ts +31 -15
  34. package/src/modes/components/status-line/segments.ts +6 -1
  35. package/src/modes/components/thinking-selector.ts +7 -3
  36. package/src/modes/controllers/selector-controller.ts +16 -3
  37. package/src/sdk/acp/adapter.ts +63 -1
  38. package/src/sdk/acp/final-text.ts +19 -2
  39. package/src/sdk/broker/ensure.ts +97 -19
  40. package/src/sdk/bus/index.ts +31 -4
  41. package/src/sdk/host/session-runtime.test.ts +498 -7
  42. package/src/sdk/host/session-runtime.ts +188 -21
  43. package/src/sdk/prompt-deadline-manager.ts +3 -3
  44. package/src/sdk/prompt-failure.ts +43 -1
  45. package/src/sdk/prompt-status.ts +8 -0
  46. package/src/sdk/router/replay-retention-gap-cache.ts +27 -0
  47. package/src/sdk/router/session-router.ts +22 -3
  48. package/src/sdk/session.ts +12 -9
  49. package/src/sdk/transport/serve-cli.ts +105 -15
  50. package/src/session-import/codex.ts +12 -3
  51. package/src/thinking.ts +21 -2
  52. package/src/utils/mupdf-wasm-embedded.ts +3 -0
  53. package/src/utils/mupdf-wasm.ts +24 -2
@@ -46,14 +46,20 @@ import {
46
46
  AcpSdkAdapterError,
47
47
  acpMcpLaunchFailure,
48
48
  } from "../../sdk/acp";
49
- import { resolveAcpFinalText } from "../../sdk/acp/final-text";
49
+ import { hasAcpFinalTextContent, resolveAcpFinalText } from "../../sdk/acp/final-text";
50
50
  import type { SessionLifecycleMcpServer } from "../../sdk/acp/mcp";
51
51
  import { ensureBroker } from "../../sdk/broker/ensure";
52
52
  import { canonicalSessionCwd } from "../../sdk/broker/session-index";
53
53
  import { readSdkBrokerDiscovery, SdkClient, SdkClientError } from "../../sdk/client";
54
54
  import type { AbortScope } from "../../sdk/host/control/operations";
55
55
  import { SYNTHETIC_PROVIDER_ID } from "../../sdk/model-profile-namespace";
56
- import { failedPromptOutcome, isSdkPromptFailurePhase, rephaseFailedOutcome } from "../../sdk/prompt-failure";
56
+ import {
57
+ failedPromptOutcome,
58
+ isSafePromptFailureCode,
59
+ isSdkPromptFailurePhase,
60
+ promptFailureRetryability,
61
+ rephaseFailedOutcome,
62
+ } from "../../sdk/prompt-failure";
57
63
  import type { SdkPromptTerminalOutcome } from "../../sdk/prompt-status";
58
64
  import { PromptActivity, type PromptWatchdogClock, systemPromptWatchdogClock } from "../../sdk/prompt-watchdog";
59
65
  import { validateRequiredPromptText } from "../../sdk/protocol/adapter-validation";
@@ -177,6 +183,8 @@ interface PromptWaiter {
177
183
  lastFrameAt: number;
178
184
  /** Type of that prompt-owned frame, reported when the watchdog expires. */
179
185
  lastFrameType: string;
186
+ /** True while the prompt is blocked in provider preflight, before the turn is dispatched. */
187
+ awaitingProviderPreflight: boolean;
180
188
  /** Cancels the armed inactivity watchdog; re-armed by prompt-owned frames. */
181
189
  cancelWatchdog?: () => void;
182
190
  /** What the host is observably doing — a tool running, a model call unanswered — and the bound that follows from it. */
@@ -185,6 +193,12 @@ interface PromptWaiter {
185
193
  observedTurnActivity: boolean;
186
194
  /** True once a prompt-owned tool execution frame was observed; vetoes a first-turn retry that would re-run it. */
187
195
  observedToolExecution: boolean;
196
+ /**
197
+ * Newest `plan` update published for this prompt, retained so an abandoned turn can report
198
+ * what it had left to do (issue #5669). Absent means no plan was ever observed, which is
199
+ * evidence of nothing — distinct from an observed plan whose entries are all `completed`.
200
+ */
201
+ planSnapshot?: AcpPlanSnapshot;
188
202
  /** Coordinates a prompt-control rejection racing an acknowledged ACP cancellation. */
189
203
  cancelAttempt?: Promise<boolean>;
190
204
  /** Whether ANY overlapping cancel attempt for this prompt was acknowledged:
@@ -618,7 +632,7 @@ type SdkPromptFailedOutcome = Extract<SdkPromptTerminalOutcome, { kind: "failed"
618
632
  * category — so settlement carries that classification instead of discarding it into a bare
619
633
  * `AcpSdkAdapterError` (review P1, issue #5574).
620
634
  */
621
- class AcpPromptFailureError extends AcpSdkAdapterError {
635
+ export class AcpPromptFailureError extends AcpSdkAdapterError {
622
636
  readonly failure: SdkPromptFailedOutcome;
623
637
  constructor(failure: SdkPromptFailedOutcome) {
624
638
  super(failure.code, failure.message);
@@ -626,6 +640,103 @@ class AcpPromptFailureError extends AcpSdkAdapterError {
626
640
  }
627
641
  }
628
642
 
643
+ /**
644
+ * The wire projection of a prompt terminal's classification (issue #5615).
645
+ *
646
+ * `super(failure.code, failure.message)` above narrows the error to the generic
647
+ * `prompt_failed` plus its fixed redacted message, so the classification the
648
+ * terminal already computed used to die at the JSON-RPC boundary: an ACP client
649
+ * saw only `-32603 {code, details}` and could not tell a transient provider blip
650
+ * from a dead session without parsing English. These fields carry it across.
651
+ *
652
+ * `providerCode` is re-checked against the safe-token rule rather than trusted:
653
+ * `terminalOutcome` accepts the host's `providerCode` on a `typeof` check alone,
654
+ * and this is the first path that puts it on the wire, so an unbounded value is
655
+ * dropped instead of becoming a leak of provider text (the redaction contract in
656
+ * `sanitizePromptFailure`). A dropped or absent code is omitted, never nulled.
657
+ */
658
+ function promptFailureWireData(failure: SdkPromptFailedOutcome): Record<string, string> {
659
+ return {
660
+ phase: failure.phase,
661
+ category: failure.category,
662
+ retryability: promptFailureRetryability(failure.category),
663
+ ...(isSafePromptFailureCode(failure.providerCode) ? { providerCode: failure.providerCode } : {}),
664
+ };
665
+ }
666
+
667
+ /**
668
+ * What an ACP `plan` update said the turn intended to do. Structural rather than the SDK's
669
+ * `PlanEntry` so only the fields this evidence reads are depended on.
670
+ */
671
+ type AcpPlanSnapshot = Array<{
672
+ content: string;
673
+ status: "pending" | "in_progress" | "completed";
674
+ _meta?: { gjcTodoStatus?: unknown } | null;
675
+ }>;
676
+
677
+ /**
678
+ * An abandoned prompt that still carries the plan its turn was working through (issue #5669).
679
+ *
680
+ * The watchdog settles a prompt whose host stopped producing frames, and that rejection used to
681
+ * be a bare `AcpSdkAdapterError`: `{code, details}` and nothing else. A wrapper could not tell
682
+ * "the agent finished" from "the agent stopped with work left", so an abandoned turn's unverified
683
+ * diff was published as if it were complete. The last observed plan is the evidence that settles
684
+ * that question, so settlement carries it instead of dropping it at the JSON-RPC boundary.
685
+ */
686
+ export class AcpPromptAbandonedError extends AcpSdkAdapterError {
687
+ readonly plan: AcpPlanSnapshot | undefined;
688
+ constructor(code: string, message: string, plan: AcpPlanSnapshot | undefined) {
689
+ super(code, message);
690
+ this.plan = plan;
691
+ }
692
+ }
693
+
694
+ /** Most pending plan entries that may reach the wire. */
695
+ const ACP_ABANDON_PLAN_MAX_ENTRIES = 10;
696
+ /** Longest one pending entry's text may be on the wire. */
697
+ const ACP_ABANDON_PLAN_MAX_CONTENT_CHARS = 200;
698
+
699
+ /**
700
+ * Whether a plan entry describes work the turn never finished (issue #5669).
701
+ *
702
+ * An entry whose ACP status is `completed` can still be unfinished: ACP's `PlanEntryStatus` has no
703
+ * `abandoned` member, so a task dropped via `/todo drop` is projected as `completed` for the
704
+ * client's plan UI while the mapper keeps the internal status in `_meta.gjcTodoStatus`. Completion
705
+ * evidence has to read that internal truth — reading the wire status alone would report a plan of
706
+ * nothing but dropped tasks as a finished turn. `_meta` crosses a JSON boundary, so the internal
707
+ * status is compared as `unknown` rather than asserted into a type.
708
+ */
709
+ function planEntryUnfinished(entry: AcpPlanSnapshot[number]): boolean {
710
+ return entry.status !== "completed" || entry._meta?.gjcTodoStatus === "abandoned";
711
+ }
712
+
713
+ /**
714
+ * The wire projection of an abandoned prompt's plan (issue #5669).
715
+ *
716
+ * Plan text is model-authored, so it is bounded the same way `promptFailureWireData` bounds a
717
+ * provider classifier: a capped number of entries, each capped in length, and never restated in
718
+ * the human message. The counts are what a wrapper actually gates on; the contents are there so
719
+ * the reader can name the unfinished steps without a log dive.
720
+ *
721
+ * An absent plan yields no keys at all. "No plan was ever observed" is not the same claim as
722
+ * "the plan was complete", and a client must be able to tell them apart, so the fields are
723
+ * omitted rather than nulled or reported as unknown.
724
+ */
725
+ function promptAbandonWireData(plan: AcpPlanSnapshot | undefined): Record<string, string> {
726
+ if (!plan) return {};
727
+ const pending = plan.filter(planEntryUnfinished);
728
+ return {
729
+ planIncomplete: pending.length > 0 ? "true" : "false",
730
+ planPendingCount: String(pending.length),
731
+ planTotalCount: String(plan.length),
732
+ planPending: JSON.stringify(
733
+ pending
734
+ .slice(0, ACP_ABANDON_PLAN_MAX_ENTRIES)
735
+ .map(entry => entry.content.slice(0, ACP_ABANDON_PLAN_MAX_CONTENT_CHARS)),
736
+ ),
737
+ };
738
+ }
739
+
629
740
  /**
630
741
  * The startup-readiness failure class (issue #5574): the turn failed AFTER it started, with a
631
742
  * bounded provider/transport classifier — the signature of a host that accepted the turn before
@@ -1251,9 +1362,21 @@ export function acpRequestFailure(error: unknown): unknown {
1251
1362
  const code = typeof error === "object" && error !== null && "code" in error ? error.code : undefined;
1252
1363
  if (typeof code !== "string") return error;
1253
1364
  const message = error instanceof Error ? error.message : code;
1365
+ // A prompt terminal additionally publishes its classification (issue #5615). The
1366
+ // spread is empty for every other error, so their payloads are byte-identical to
1367
+ // before. `code`/`details` and the JSON-RPC code itself are untouched either way:
1368
+ // this enriches `data`, it does not restate the redacted message.
1369
+ const data =
1370
+ error instanceof AcpPromptFailureError
1371
+ ? { code, details: message, ...promptFailureWireData(error.failure) }
1372
+ : { code, details: message };
1373
+ // An abandoned prompt additionally publishes the plan it never finished (issue #5669).
1374
+ // Same rule as above: `code`/`details` and the JSON-RPC code are untouched, and an abandon
1375
+ // that observed no plan adds no keys, so its payload stays byte-identical to before.
1376
+ if (error instanceof AcpPromptAbandonedError) Object.assign(data, promptAbandonWireData(error.plan));
1254
1377
  switch (code) {
1255
1378
  case "authentication_failed":
1256
- return RequestError.authRequired({ code, details: message }, message);
1379
+ return RequestError.authRequired(data, message);
1257
1380
  // `not_found` stays -32603 with its discriminator in `data`: ACP's
1258
1381
  // `resourceNotFound` (-32002) is a URI-addressed resource error, and an unknown
1259
1382
  // session id is not a resource URI. Pinned ACP core-v1 conformance also requires
@@ -1261,12 +1384,15 @@ export function acpRequestFailure(error: unknown): unknown {
1261
1384
  case "invalid_input":
1262
1385
  case "unsupported":
1263
1386
  case "unsupported_content":
1264
- return RequestError.invalidParams({ code, details: message }, message);
1387
+ return RequestError.invalidParams(data, message);
1265
1388
  default:
1266
1389
  // The remaining internal codes (conflict, unavailable, busy, …) have no ACP
1267
1390
  // counterpart and stay -32603. Keep the discriminator in `data` so a client can
1268
- // branch on retry/reconnect instead of parsing an English message.
1269
- return RequestError.internalError({ code, details: message }, message);
1391
+ // branch on retry/reconnect instead of parsing an English message. `prompt_failed`
1392
+ // and `prompt_deadline_exceeded` land here and keep -32603 by design: pinned ACP
1393
+ // core-v1 conformance expects -32603/-32000 for this class, so the added
1394
+ // classification travels in `data` rather than in a renumbered code.
1395
+ return RequestError.internalError(data, message);
1270
1396
  }
1271
1397
  }
1272
1398
 
@@ -2055,6 +2181,7 @@ export class AcpAgent implements Agent {
2055
2181
  deferredActivityFrames: [],
2056
2182
  lastFrameAt: this.#promptWatchdogClock.now(),
2057
2183
  lastFrameType: "prompt_dispatch",
2184
+ awaitingProviderPreflight: true,
2058
2185
  activity: new PromptActivity(),
2059
2186
  observedTurnActivity: false,
2060
2187
  observedToolExecution: false,
@@ -2066,9 +2193,24 @@ export class AcpAgent implements Agent {
2066
2193
  // pending. Retain the original promise for the caller, but mark that delayed rejection
2067
2194
  // as observed until prompt() can resume and await it.
2068
2195
  void response.catch(() => undefined);
2196
+ // Silence has to be bounded from the moment the prompt owns the session: a host that
2197
+ // dies before it ever answers is exactly the failure that leaves the client running.
2198
+ // Provider preflight is part of that window — it awaits a lease the host may never win
2199
+ // — so the bound is armed before it, not after (issue #5658). The arm below re-arms
2200
+ // this same waiter once preflight clears, and #armPromptWatchdog cancels the previous
2201
+ // timer first, so only one timer is ever live.
2202
+ this.#armPromptWatchdog(params.sessionId, record, waiter);
2203
+ // The watchdog settles `response`, not this await, so a preflight that never returns
2204
+ // would pin session/prompt open with the caller's promise already rejected. Racing the
2205
+ // settlement lets the re-checks below hand that rejection back to the caller.
2206
+ const settlement = response.then(
2207
+ () => undefined,
2208
+ () => undefined,
2209
+ );
2069
2210
  try {
2070
- await record.adapter.ensureProviders();
2211
+ await Promise.race([record.adapter.ensureProviders(), settlement]);
2071
2212
  } catch (error) {
2213
+ waiter.awaitingProviderPreflight = false;
2072
2214
  if (waiter.settled || record.activePrompt !== waiter) {
2073
2215
  this.#retiredPromptAcknowledgements.delete(params.sessionId);
2074
2216
  return await response;
@@ -2081,11 +2223,13 @@ export class AcpAgent implements Agent {
2081
2223
  if (record.activePrompt === waiter) {
2082
2224
  record.activePrompt = undefined;
2083
2225
  record.busy = record.backgroundBusy;
2226
+ clearPromptWatchdog(waiter);
2084
2227
  this.#retiredPromptAcknowledgements.delete(params.sessionId);
2085
2228
  void this.#publishPromptPhaseIdle(params.sessionId, record.adapter);
2086
2229
  }
2087
2230
  throw error;
2088
2231
  }
2232
+ waiter.awaitingProviderPreflight = false;
2089
2233
  if (waiter.settled || record.activePrompt !== waiter) {
2090
2234
  this.#retiredPromptAcknowledgements.delete(params.sessionId);
2091
2235
  return await response;
@@ -2096,8 +2240,9 @@ export class AcpAgent implements Agent {
2096
2240
  return await response;
2097
2241
  }
2098
2242
 
2099
- // Silence has to be bounded from the moment the prompt owns the session: a host that
2100
- // dies before it ever answers is exactly the failure that leaves the client running.
2243
+ // Re-arm now that preflight is behind us: the bound armed before it already covered
2244
+ // this prompt's ownership window, and this call replaces that timer rather than
2245
+ // racing it, so the dispatched turn gets the full inactivity bound of its own.
2101
2246
  this.#armPromptWatchdog(params.sessionId, record, waiter);
2102
2247
  // Echo the user's own message back as `user_message_chunk`. Clients render their
2103
2248
  // transcript from session/update, so without this a prompt's text and any attached
@@ -3257,15 +3402,32 @@ export class AcpAgent implements Agent {
3257
3402
  async #expirePromptWatchdog(id: string, record: SessionRecord, waiter: PromptWaiter): Promise<void> {
3258
3403
  if (record.activePrompt !== waiter || waiter.settled || waiter.terminalReserved) return;
3259
3404
  const silenceMs = Math.max(0, this.#promptWatchdogClock.now() - waiter.lastFrameAt);
3260
- const cause = this.#promptTransportGone(id, record) ?? "the SDK session host stopped producing frames";
3405
+ // A prompt still in preflight has never reached the host, so "stopped producing frames"
3406
+ // would point the reader at the wrong phase: nothing was ever dispatched to produce them.
3407
+ const cause =
3408
+ this.#promptTransportGone(id, record) ??
3409
+ (waiter.awaitingProviderPreflight
3410
+ ? "the SDK session host never finished provider preflight"
3411
+ : "the SDK session host stopped producing frames");
3412
+ // Counts only: the plan's contents are model-authored and belong on the bounded wire
3413
+ // projection, not in the log line (issue #5669).
3414
+ const plan = waiter.planSnapshot;
3261
3415
  logger.error("acp_prompt_watchdog_expired", {
3262
3416
  sessionId: id,
3263
3417
  cause,
3264
3418
  silenceMs,
3265
3419
  lastFrameType: waiter.lastFrameType,
3420
+ awaitingProviderPreflight: waiter.awaitingProviderPreflight,
3266
3421
  inactivityBoundMs: waiter.activity.inactivityBoundMs,
3267
3422
  toolRunning: waiter.activity.running,
3268
3423
  awaitingModel: waiter.activity.awaitingModel,
3424
+ ...(plan
3425
+ ? {
3426
+ planIncomplete: plan.some(planEntryUnfinished),
3427
+ planPendingCount: plan.filter(planEntryUnfinished).length,
3428
+ planTotalCount: plan.length,
3429
+ }
3430
+ : {}),
3269
3431
  ...(waiter.correlation.commandId ? { commandId: waiter.correlation.commandId } : {}),
3270
3432
  ...(waiter.correlation.turnId ? { turnId: waiter.correlation.turnId } : {}),
3271
3433
  });
@@ -3273,11 +3435,12 @@ export class AcpAgent implements Agent {
3273
3435
  record,
3274
3436
  id,
3275
3437
  waiter,
3276
- new AcpSdkAdapterError(
3438
+ new AcpPromptAbandonedError(
3277
3439
  "prompt_abandoned",
3278
3440
  `ACP prompt was abandoned after ${Math.round(silenceMs / 1_000)}s of silence: ${cause}. Last frame was ` +
3279
3441
  `"${waiter.lastFrameType}" (${describeCorrelation(waiter.correlation)}). The turn was settled so the ` +
3280
3442
  `client stops waiting; the session still accepts the next prompt.`,
3443
+ plan,
3281
3444
  ),
3282
3445
  );
3283
3446
  }
@@ -3467,7 +3630,11 @@ export class AcpAgent implements Agent {
3467
3630
  // the first-turn retry gate — which the settlement below releases — would see
3468
3631
  // "started, no output", resubmit, and let BOTH this terminal's final text and the
3469
3632
  // retry's answer reach ACP consumers. Record the output before settling (review P1).
3470
- if (typeof event.finalText === "string" && event.finalText) record.promptObservedAssistantOutput = true;
3633
+ // Whitespace-only final text carries no assistant content. `hasAcpFinalTextContent` is the
3634
+ // single presence predicate shared with the terminal publication gate below, so a terminal
3635
+ // this gate treats as "no output" can never publish a chunk alongside the retry's answer.
3636
+ if (typeof event.finalText === "string" && hasAcpFinalTextContent(event.finalText))
3637
+ record.promptObservedAssistantOutput = true;
3471
3638
  // Failure diagnostics are useful but advisory. Settle before any mapped
3472
3639
  // session update can await a backpressured client transport; otherwise an
3473
3640
  // already-decided failure can still lose to the inactivity watchdog.
@@ -3534,6 +3701,12 @@ export class AcpAgent implements Agent {
3534
3701
  notification.update.content.type === "text"
3535
3702
  )
3536
3703
  promptOwner.emittedAssistantText += notification.update.content.text;
3704
+ // The newest plan REPLACES the retained one: a plan update is the whole list, not a
3705
+ // delta, so `todo_auto_clear`'s empty `entries` correctly leaves no pending work behind
3706
+ // (issue #5669). Retained here rather than at frame ingress because this is where the
3707
+ // mapper has already decided what the client is being told the plan is.
3708
+ if (promptOwner && notification.update.sessionUpdate === "plan")
3709
+ promptOwner.planSnapshot = notification.update.entries;
3537
3710
  // A live assistant text/thought chunk for this prompt owner has now been exposed to
3538
3711
  // ACP consumers. Record it so the first-turn readiness retry is vetoed: a re-submit
3539
3712
  // would deliver a second attempt's output on top of these chunks (issue #5574).
@@ -3628,7 +3801,7 @@ export class AcpAgent implements Agent {
3628
3801
  if (promptOwner) this.#flushFailureDiagnostics(id, promptOwner, adapter);
3629
3802
  let decorationStart = Promise.resolve();
3630
3803
  const finalText = typeof event.finalText === "string" ? event.finalText : "";
3631
- if (promptOwner && finalText) {
3804
+ if (promptOwner && hasAcpFinalTextContent(finalText)) {
3632
3805
  const finalTextTask = (async () => {
3633
3806
  await Bun.sleep(0);
3634
3807
  const resolution = resolveAcpFinalText(promptOwner.emittedAssistantText, finalText);
@@ -239,6 +239,7 @@ export function mapAgentSessionEventToAcpSessionUpdates(
239
239
  content: todo.content,
240
240
  priority: "medium" as const,
241
241
  status: mapTodoStatus(todo.status),
242
+ _meta: { gjcTodoStatus: todo.status },
242
243
  }));
243
244
  return [toSessionNotification(sessionId, { sessionUpdate: "plan", entries })];
244
245
  }
@@ -502,6 +503,13 @@ function toSessionNotification(sessionId: string, update: SessionUpdate): Sessio
502
503
  return { sessionId, update };
503
504
  }
504
505
 
506
+ /**
507
+ * ACP's `PlanEntryStatus` has only three members, so an `abandoned` todo has to be projected as
508
+ * one of them and `completed` is the one that stops a client's plan UI from showing a dropped task
509
+ * as outstanding work. That projection is lossy, so every plan entry also carries the internal
510
+ * status as `_meta.gjcTodoStatus` for readers that need the truth rather than the rendering
511
+ * (issue #5669).
512
+ */
505
513
  const todoStatusMap: Record<TodoStatus, "pending" | "in_progress" | "completed"> = {
506
514
  pending: "pending",
507
515
  in_progress: "in_progress",
@@ -529,6 +537,7 @@ function mapTodoWriteResultToPlanUpdate(
529
537
  content: todo.content,
530
538
  priority: "medium" as const,
531
539
  status: mapTodoStatus(todo.status),
540
+ _meta: { gjcTodoStatus: todo.status },
532
541
  })),
533
542
  };
534
543
  }
@@ -1,5 +1,11 @@
1
1
  import { ThinkingLevel } from "@gajae-code/agent-core";
2
- import { getSupportedEfforts, type Model, modelSupportsServiceTier, modelsAreEqual } from "@gajae-code/ai/core";
2
+ import {
3
+ getMiniMaxThinkingMode,
4
+ getSupportedEfforts,
5
+ type Model,
6
+ modelSupportsServiceTier,
7
+ modelsAreEqual,
8
+ } from "@gajae-code/ai/core";
3
9
  import {
4
10
  Container,
5
11
  fuzzyFilter,
@@ -26,6 +32,7 @@ import type { BillingPath } from "../../config/billing-path";
26
32
  import {
27
33
  getProxyRoutableProviders,
28
34
  inspectProxyProviderId,
35
+ isModelProfileProxyConfigured,
29
36
  requiresQualifiedModelProfileRoleResolution,
30
37
  resolveProxyMode,
31
38
  rewriteSelectorForProxy,
@@ -426,6 +433,7 @@ export class ModelSelectorComponent extends Container {
426
433
  #presetScopeMenuOpen: boolean = false;
427
434
  #presetScopeIndex: number = 0;
428
435
  #providerAuthById = new Map<string, boolean>();
436
+ #credentiallessProviders = new Set<string>();
429
437
  #bareProfileAuthByName = new Map<string, boolean>();
430
438
  // Rebuilt with the provider and bare-selector snapshots on every catalog or credential refresh.
431
439
  #profileAuthByName = new Map<string, boolean>();
@@ -1441,17 +1449,16 @@ export class ModelSelectorComponent extends Container {
1441
1449
  const proxyProvider = tryResolveProxyProviderId(this.#settings);
1442
1450
  if (
1443
1451
  proxyProvider !== undefined &&
1444
- !(this.#modelRegistry.getConfiguredProviderIds?.() ?? []).includes(proxyProvider)
1452
+ !isModelProfileProxyConfigured(
1453
+ proxyProvider,
1454
+ this.#modelRegistry.getConfiguredProviderIds?.(),
1455
+ this.#credentiallessProviders.has(proxyProvider),
1456
+ )
1445
1457
  )
1446
1458
  return false;
1447
1459
  try {
1448
1460
  if (resolveProxyMode(this.#settings) === "always") {
1449
- const configuredProviders = this.#modelRegistry.getConfiguredProviderIds?.() ?? [];
1450
- if (
1451
- proxyProvider === undefined ||
1452
- this.#isProviderAuthenticated(proxyProvider) !== true ||
1453
- !configuredProviders.includes(proxyProvider)
1454
- )
1461
+ if (proxyProvider === undefined || this.#isProviderAuthenticated(proxyProvider) !== true)
1455
1462
  return false;
1456
1463
  }
1457
1464
  } catch {
@@ -1530,6 +1537,7 @@ export class ModelSelectorComponent extends Container {
1530
1537
  async #refreshProviderAuth(): Promise<void> {
1531
1538
  const refreshGeneration = ++this.#providerAuthRefreshGeneration;
1532
1539
  this.#providerAuthById = new Map();
1540
+ this.#credentiallessProviders = new Set();
1533
1541
  this.#bareProfileAuthByName = new Map();
1534
1542
  this.#profileAuthByName = new Map();
1535
1543
  const availableModels = this.#getProfileAvailableModels();
@@ -1586,9 +1594,9 @@ export class ModelSelectorComponent extends Container {
1586
1594
  [...providers].map(async provider => {
1587
1595
  try {
1588
1596
  const apiKey = await this.#modelRegistry.getApiKeyForProvider(provider, this.#authSessionId);
1589
- return [provider, apiKey === kNoAuth || isAuthenticated(apiKey)] as const;
1597
+ return [provider, apiKey === kNoAuth || isAuthenticated(apiKey), apiKey === kNoAuth] as const;
1590
1598
  } catch {
1591
- return [provider, false] as const;
1599
+ return [provider, false, false] as const;
1592
1600
  }
1593
1601
  }),
1594
1602
  );
@@ -1598,7 +1606,10 @@ export class ModelSelectorComponent extends Container {
1598
1606
  refreshGeneration !== this.#providerAuthRefreshGeneration
1599
1607
  )
1600
1608
  return;
1601
- this.#providerAuthById = new Map(entries);
1609
+ this.#providerAuthById = new Map(entries.map(([provider, authenticated]) => [provider, authenticated]));
1610
+ this.#credentiallessProviders = new Set(
1611
+ entries.filter(([, , keyless]) => keyless).map(([provider]) => provider),
1612
+ );
1602
1613
  const profileAuthEntries = await Promise.all(
1603
1614
  [...this.#getPresetGroups().values()].flat().map(async profile => {
1604
1615
  const bindings = resolveProfileBindings(profile);
@@ -1853,7 +1864,7 @@ export class ModelSelectorComponent extends Container {
1853
1864
  const modelLabel = sanitizeText(`${model.provider}/${model.id}`).replace(/\s+/g, " ").trim();
1854
1865
  let label = modelLabel;
1855
1866
  if (thinkingLevel && thinkingLevel !== ThinkingLevel.Inherit) {
1856
- label += ` (${getThinkingLevelMetadata(thinkingLevel).label})`;
1867
+ label += ` (${getThinkingLevelMetadata(thinkingLevel, model).label})`;
1857
1868
  }
1858
1869
  const billing = this.#billingPathForModel(model)?.label;
1859
1870
  if (billing) label += ` [${billing}]`;
@@ -2214,7 +2225,7 @@ export class ModelSelectorComponent extends Container {
2214
2225
  const assigned = this.#roles[role];
2215
2226
  if (roleInfo.tag && assigned && modelsAreEqual(assigned.model, item.model)) {
2216
2227
  const badge = makeInvertedBadge(roleInfo.tag, roleInfo.color ?? "muted");
2217
- const thinkingLabel = getThinkingLevelMetadata(assigned.thinkingLevel).label;
2228
+ const thinkingLabel = getThinkingLevelMetadata(assigned.thinkingLevel, item.model).label;
2218
2229
 
2219
2230
  // Subagent roles (task.agentModelOverrides) run under task.serviceTier,
2220
2231
  // so their ⚡ uses the effective subagent tier. A non-subagent
@@ -2326,6 +2337,11 @@ export class ModelSelectorComponent extends Container {
2326
2337
  #renderActionMenu(item: ModelItem | CanonicalModelItem): void {
2327
2338
  this.#listContainer.addChild(new Spacer(1));
2328
2339
  this.#listContainer.addChild(new Text(theme.fg("muted", ` Action for: ${item.model.id}`), 0, 0));
2340
+ if (getMiniMaxThinkingMode(item.model) === "always-on") {
2341
+ this.#listContainer.addChild(
2342
+ new Text(theme.fg("muted", " Reasoning: always on; effort control is unsupported"), 0, 0),
2343
+ );
2344
+ }
2329
2345
  this.#listContainer.addChild(new Spacer(1));
2330
2346
  const actionCount = this.#getActionCount(item.model);
2331
2347
  for (let i = 0; i < actionCount; i++) {
@@ -2390,7 +2406,7 @@ export class ModelSelectorComponent extends Container {
2390
2406
  // Show the highlighted reasoning level — never the model id (that mislabel
2391
2407
  // made "Reasoning for Default: gpt-5.6-luna" look like a level).
2392
2408
  const selectedLevel = choice.levels[this.#selectedThinkingIndex];
2393
- const selectedLevelLabel = selectedLevel ? getThinkingLevelMetadata(selectedLevel).label : "—";
2409
+ const selectedLevelLabel = selectedLevel ? getThinkingLevelMetadata(selectedLevel, choice.item.model).label : "—";
2394
2410
  this.#listContainer.addChild(new Spacer(1));
2395
2411
  this.#listContainer.addChild(
2396
2412
  new Text(theme.fg("muted", ` Reasoning for ${targetLabel}: ${selectedLevelLabel}`), 0, 0),
@@ -2398,7 +2414,7 @@ export class ModelSelectorComponent extends Container {
2398
2414
  this.#listContainer.addChild(new Spacer(1));
2399
2415
  for (let i = 0; i < choice.levels.length; i++) {
2400
2416
  const level = choice.levels[i];
2401
- const metadata = getThinkingLevelMetadata(level);
2417
+ const metadata = getThinkingLevelMetadata(level, choice.item.model);
2402
2418
  const prefix = i === this.#selectedThinkingIndex ? theme.fg("accent", `${theme.nav.cursor} `) : " ";
2403
2419
  const label = `${metadata.label} — ${metadata.description}`;
2404
2420
  this.#listContainer.addChild(
@@ -1,9 +1,11 @@
1
1
  import * as os from "node:os";
2
2
  import * as path from "node:path";
3
3
  import { ThinkingLevel } from "@gajae-code/agent-core";
4
+ import { getMiniMaxThinkingMode } from "@gajae-code/ai/model-thinking";
4
5
  import { TERMINAL, truncateToWidth } from "@gajae-code/tui";
5
6
  import { formatDuration, formatNumber, getProjectDir, pathIsWithin, relativePathWithinRoot } from "@gajae-code/utils";
6
7
  import { type ThemeColor, theme } from "../../../modes/theme/theme";
8
+ import { getThinkingLevelMetadata } from "../../../thinking";
7
9
  import { shortenPath } from "../../../tools/render-utils";
8
10
  import { getSessionAccentAnsi, getSessionAccentHex } from "../../../utils/session-color";
9
11
  import { sanitizeStatusText } from "../../shared";
@@ -113,7 +115,10 @@ const modelSegment: StatusLineSegment = {
113
115
  }
114
116
 
115
117
  // Add thinking level with dot separator
116
- if (opts.showThinkingLevel !== false && state.model?.thinking) {
118
+ const miniMaxMode = state.model && getMiniMaxThinkingMode(state.model);
119
+ if (opts.showThinkingLevel !== false && miniMaxMode) {
120
+ content += `${theme.sep.dot}${getThinkingLevelMetadata(state.thinkingLevel ?? ThinkingLevel.Off, state.model).label}`;
121
+ } else if (opts.showThinkingLevel !== false && state.model?.thinking) {
117
122
  const level = state.thinkingLevel ?? ThinkingLevel.Off;
118
123
  if (level !== ThinkingLevel.Off) {
119
124
  const thinkingText = theme.thinking[level as keyof typeof theme.thinking];
@@ -1,7 +1,8 @@
1
1
  import { ThinkingLevel, type ThinkingLevel as ThinkingLevelValue } from "@gajae-code/agent-core";
2
+ import type { Model } from "@gajae-code/ai/types";
2
3
  import { Container, type SelectItem, SelectList, Text } from "@gajae-code/tui";
3
4
  import { getSelectListTheme, theme } from "../../modes/theme/theme";
4
- import { getThinkingLevelMetadata, type ThinkingLevelValue as ThinkingMetadataValue } from "../../thinking-metadata";
5
+ import { getThinkingLevelMetadata } from "../../thinking";
5
6
  import { DynamicBorder } from "./dynamic-border";
6
7
 
7
8
  const SCOPE_ITEMS = [
@@ -31,21 +32,24 @@ export class ThinkingSelectorComponent extends Container {
31
32
  #selectedLevel: ThinkingLevelValue | undefined;
32
33
  readonly #onSelect: (selection: ThinkingSelectorSelection) => void;
33
34
  readonly #onCancel: () => void;
35
+ readonly #model: Model | undefined;
34
36
 
35
37
  constructor(
36
38
  currentLevel: ThinkingLevelValue | undefined,
37
39
  availableLevels: ThinkingLevelValue[],
38
40
  onSelect: (selection: ThinkingSelectorSelection) => void,
39
41
  onCancel: () => void,
42
+ model?: Model,
40
43
  ) {
41
44
  super();
42
45
 
43
46
  this.#onSelect = onSelect;
44
47
  this.#onCancel = onCancel;
48
+ this.#model = model;
45
49
 
46
50
  const currentValue = currentLevel ?? ThinkingLevel.Off;
47
51
  const thinkingLevels: SelectItem[] = availableLevels.map(level => {
48
- const metadata = getThinkingLevelMetadata(level as ThinkingMetadataValue);
52
+ const metadata = getThinkingLevelMetadata(level, model);
49
53
  return level === currentValue
50
54
  ? { ...metadata, label: metadata.label + theme.fg("muted", " (current)") }
51
55
  : metadata;
@@ -96,7 +100,7 @@ export class ThinkingSelectorComponent extends Container {
96
100
  this.#renderLevelList();
97
101
  };
98
102
 
99
- const metadata = getThinkingLevelMetadata(level as ThinkingMetadataValue);
103
+ const metadata = getThinkingLevelMetadata(level, this.#model);
100
104
  this.detachAll();
101
105
  this.#selectList = scopeList;
102
106
  this.addChild(new DynamicBorder());
@@ -4,6 +4,7 @@ import {
4
4
  type Api,
5
5
  type AuthCredentialSelector,
6
6
  type CredentialRemovalTarget,
7
+ getMiniMaxThinkingMode,
7
8
  isSqliteCorruptionError,
8
9
  isSqliteError,
9
10
  type Model,
@@ -155,6 +156,7 @@ import {
155
156
  MODEL_ONBOARDING_SETUP_COMMAND,
156
157
  } from "../../setup/model-onboarding-guidance";
157
158
  import { addApiCompatibleProvider, formatProviderSetupResult } from "../../setup/provider-onboarding";
159
+ import { getThinkingLevelMetadata } from "../../thinking";
158
160
  import {
159
161
  isConfigurableSearchProviderId,
160
162
  isSearchProviderPreference,
@@ -1847,6 +1849,11 @@ export class SelectorController {
1847
1849
  }
1848
1850
 
1849
1851
  showEffortSelector(): void {
1852
+ const model = this.ctx.session.model;
1853
+ if (model && getMiniMaxThinkingMode(model) === "always-on") {
1854
+ this.ctx.showStatus("Reasoning is always on for MiniMax M2.x; disabling and effort control are unsupported.");
1855
+ return;
1856
+ }
1850
1857
  const availableLevels = [
1851
1858
  ThinkingLevel.Inherit,
1852
1859
  ThinkingLevel.Off,
@@ -1871,9 +1878,14 @@ export class SelectorController {
1871
1878
 
1872
1879
  const effectiveLevel = this.ctx.session.thinkingLevel ?? ThinkingLevel.Off;
1873
1880
  const requestedLabel =
1874
- level === ThinkingLevel.Inherit ? `${level} (configured default: ${configuredDefault})` : level;
1881
+ level === ThinkingLevel.Inherit
1882
+ ? `${level} (configured default: ${configuredDefault})`
1883
+ : getThinkingLevelMetadata(level, model).label;
1884
+ const effectiveLabel = getThinkingLevelMetadata(effectiveLevel, model).label;
1875
1885
  const clampedSuffix =
1876
- effectiveLevel === levelToApply ? "" : ` Requested ${levelToApply}; effective ${effectiveLevel}.`;
1886
+ effectiveLevel === levelToApply
1887
+ ? ""
1888
+ : ` Requested ${getThinkingLevelMetadata(levelToApply, model).label}; effective ${effectiveLabel}.`;
1877
1889
 
1878
1890
  this.ctx.statusLine.invalidate();
1879
1891
  this.ctx.updateEditorBorderColor();
@@ -1882,13 +1894,14 @@ export class SelectorController {
1882
1894
  this.ctx.ui.requestRender();
1883
1895
  const scopeLabel = persistDefault ? "Default reasoning effort" : "Reasoning effort";
1884
1896
  this.ctx.showStatus(
1885
- `${scopeLabel} set to ${requestedLabel}. Effective effort: ${effectiveLevel}.${clampedSuffix}`,
1897
+ `${scopeLabel} set to ${requestedLabel}. Effective effort: ${effectiveLabel}.${clampedSuffix}`,
1886
1898
  );
1887
1899
  },
1888
1900
  () => {
1889
1901
  done();
1890
1902
  this.ctx.ui.requestRender();
1891
1903
  },
1904
+ model,
1892
1905
  );
1893
1906
  return { component: selector, focus: selector };
1894
1907
  });