@gajae-code/coding-agent 0.12.8 → 0.12.11

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 (51) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/dist/types/cli/notify-cli.d.ts +1 -0
  3. package/dist/types/commands/sdk.d.ts +23 -0
  4. package/dist/types/discovery/agents-md.d.ts +11 -1
  5. package/dist/types/modes/types.d.ts +6 -0
  6. package/dist/types/sdk/bus/chat-daemon-control.d.ts +3 -1
  7. package/dist/types/sdk/bus/daemon-paths.d.ts +1 -0
  8. package/dist/types/sdk/bus/notification-service.d.ts +23 -0
  9. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +4 -3
  10. package/dist/types/session/internal/managed-session-storage.d.ts +20 -2
  11. package/dist/types/session/internal/native-publish-outcome.d.ts +2 -2
  12. package/dist/types/session/session-manager.d.ts +17 -0
  13. package/dist/types/session/session-storage.d.ts +1 -0
  14. package/dist/types/system-prompt.d.ts +12 -1
  15. package/dist/types/tools/composer-bash-policy.d.ts +9 -1
  16. package/dist/types/tools/computer.d.ts +35 -2
  17. package/dist/types/tools/todo-write.d.ts +2 -0
  18. package/package.json +8 -8
  19. package/scripts/verify-gjc-sdk-canonicalization.ts +14 -1
  20. package/src/cli/notify-cli.ts +12 -6
  21. package/src/commands/sdk.ts +54 -0
  22. package/src/config/model-profiles.ts +8 -0
  23. package/src/config/model-registry.ts +4 -1
  24. package/src/discovery/agents-md.ts +76 -19
  25. package/src/gjc-runtime/psmux-detect.ts +12 -16
  26. package/src/gjc-runtime/tmux-sessions.ts +8 -1
  27. package/src/internal-urls/docs-index.generated.ts +6 -5
  28. package/src/modes/components/extensions/inspector-panel.ts +9 -5
  29. package/src/modes/controllers/selector-controller.ts +61 -43
  30. package/src/modes/print-mode.ts +3 -0
  31. package/src/modes/types.ts +6 -0
  32. package/src/prompts/system/system-prompt.md +1 -0
  33. package/src/prompts/tools/computer.md +6 -1
  34. package/src/sdk/bus/chat-daemon-control.ts +5 -3
  35. package/src/sdk/bus/daemon-paths.ts +2 -0
  36. package/src/sdk/bus/notification-service.ts +140 -7
  37. package/src/sdk/bus/telegram-daemon-contract.ts +4 -3
  38. package/src/sdk/session.ts +24 -7
  39. package/src/session/agent-session.ts +176 -34
  40. package/src/session/internal/managed-session-storage.ts +280 -248
  41. package/src/session/internal/native-publish-outcome.ts +12 -2
  42. package/src/session/session-manager.ts +140 -6
  43. package/src/session/session-storage.ts +3 -0
  44. package/src/slash-commands/builtin-registry.ts +35 -11
  45. package/src/system-prompt.ts +28 -15
  46. package/src/tools/bash.ts +3 -1
  47. package/src/tools/composer-bash-policy.ts +21 -5
  48. package/src/tools/computer.ts +322 -45
  49. package/src/tools/todo-write.ts +29 -11
  50. package/src/utils/command-args.ts +19 -30
  51. package/src/utils/lang-from-path.ts +2 -4
@@ -109,6 +109,7 @@ import {
109
109
  streamSimple,
110
110
  } from "@gajae-code/ai";
111
111
  import {
112
+ type AuthDisposition,
112
113
  beginAttempt,
113
114
  classifyFallbackTrigger,
114
115
  type FallbackAttemptToken,
@@ -426,6 +427,7 @@ import {
426
427
  getLatestCompactionEntry,
427
428
  getSessionMessageEntryId,
428
429
  getSessionMessageObservationId,
430
+ SessionAppendPersistenceError,
429
431
  transferSessionMessageIdentity,
430
432
  } from "./session-manager";
431
433
  import { getEntriesForInternalRead, getSessionContextForInternalRead } from "./session-manager-internal";
@@ -3898,14 +3900,19 @@ export class AgentSession {
3898
3900
  ) {
3899
3901
  const isRosterReminder = event.message.role === "custom" && event.message.customType === "irc-peer-roster";
3900
3902
  if (!isRosterReminder) {
3901
- this.#appendCustomMessageEntry(
3902
- event.message.customType,
3903
- event.message.content,
3904
- event.message.display,
3905
- event.message.details,
3906
- event.message.attribution ?? "agent",
3907
- getSessionMessageObservationId(event.message),
3908
- );
3903
+ try {
3904
+ this.#appendCustomMessageEntry(
3905
+ event.message.customType,
3906
+ event.message.content,
3907
+ event.message.display,
3908
+ event.message.details,
3909
+ event.message.attribution ?? "agent",
3910
+ getSessionMessageObservationId(event.message),
3911
+ );
3912
+ } catch (error) {
3913
+ this.agent.abort();
3914
+ throw error;
3915
+ }
3909
3916
  }
3910
3917
  } else if (
3911
3918
  event.message.role === "user" ||
@@ -3914,7 +3921,73 @@ export class AgentSession {
3914
3921
  event.message.role === "toolResult" ||
3915
3922
  event.message.role === "fileMention"
3916
3923
  ) {
3917
- this.sessionManager.appendMessage(event.message);
3924
+ try {
3925
+ this.sessionManager.appendMessage(event.message);
3926
+ } catch (error) {
3927
+ if (
3928
+ event.message.role !== "toolResult" ||
3929
+ event.message.toolName !== "todo_write" ||
3930
+ !(error instanceof SessionAppendPersistenceError) ||
3931
+ error.phase !== "current_append"
3932
+ ) {
3933
+ this.agent.abort();
3934
+ throw error;
3935
+ }
3936
+ this.agent.abort();
3937
+
3938
+ const failure = error.persistenceError.message;
3939
+ const failedEntryId = error.entryId;
3940
+ this.#syncTodoPhasesFromBranch();
3941
+ event.message.isError = true;
3942
+ event.message.content = [
3943
+ {
3944
+ type: "text",
3945
+ text: `Todo state persistence failed: ${failure}\nDo not change the payload solely because of this failure. The durable outcome is unknown; reconcile the session state before retrying or continuing.`,
3946
+ },
3947
+ ];
3948
+ event.message.details = {
3949
+ ...(event.message.details && typeof event.message.details === "object" ? event.message.details : {}),
3950
+ phases: this.getTodoPhases(),
3951
+ failureKind: "persistence",
3952
+ };
3953
+ this.agent.touchContext();
3954
+ let recovered = false;
3955
+ try {
3956
+ await this.sessionManager.recoverPersistenceFailure();
3957
+ recovered = true;
3958
+ } catch (recoveryError) {
3959
+ logger.warn("Todo persistence recovery failed", {
3960
+ error: recoveryError instanceof Error ? recoveryError.message : String(recoveryError),
3961
+ });
3962
+ }
3963
+ if (recovered) {
3964
+ const durableTodoResult = this.sessionManager
3965
+ .getBranch()
3966
+ .find(
3967
+ entry =>
3968
+ entry.id === failedEntryId &&
3969
+ entry.type === "message" &&
3970
+ entry.message.role === "toolResult" &&
3971
+ entry.message.toolName === "todo_write",
3972
+ );
3973
+ this.#syncTodoPhasesFromBranch();
3974
+ if (durableTodoResult?.type === "message" && durableTodoResult.message.role === "toolResult") {
3975
+ event.message.content = durableTodoResult.message.content;
3976
+ event.message.details = durableTodoResult.message.details;
3977
+ event.message.isError = durableTodoResult.message.isError;
3978
+ } else {
3979
+ event.message.details = {
3980
+ ...(event.message.details && typeof event.message.details === "object"
3981
+ ? event.message.details
3982
+ : {}),
3983
+ phases: this.getTodoPhases(),
3984
+ failureKind: "persistence",
3985
+ };
3986
+ this.sessionManager.appendMessage(event.message);
3987
+ this.agent.touchContext();
3988
+ }
3989
+ }
3990
+ }
3918
3991
  }
3919
3992
  }
3920
3993
 
@@ -4188,7 +4261,13 @@ export class AgentSession {
4188
4261
  if (event.message.role === "toolResult") {
4189
4262
  const { toolName, details, isError, content } = event.message as {
4190
4263
  toolName?: string;
4191
- details?: { path?: string; phases?: TodoPhase[]; report?: string; startedAt?: string };
4264
+ details?: {
4265
+ path?: string;
4266
+ phases?: TodoPhase[];
4267
+ report?: string;
4268
+ startedAt?: string;
4269
+ failureKind?: "payload_rejected" | "argument_validation" | "execution" | "persistence";
4270
+ };
4192
4271
  isError?: boolean;
4193
4272
  content?: Array<TextContent | ImageContent>;
4194
4273
  };
@@ -4201,22 +4280,34 @@ export class AgentSession {
4201
4280
  }
4202
4281
  if (toolName === "todo_write" && isError) {
4203
4282
  const errorText = content?.find(part => part.type === "text")?.text;
4283
+ const payloadRejected =
4284
+ details?.failureKind === "payload_rejected" || details?.failureKind === "argument_validation";
4204
4285
  const reminderText = [
4205
4286
  "<system-reminder>",
4206
- "todo_write failed, so todo progress is not visible to the user.",
4287
+ payloadRejected
4288
+ ? "todo_write rejected its payload. The requested todo update was not applied."
4289
+ : "todo_write did not complete because the turn aborted during runtime execution or persistence.",
4207
4290
  errorText ? `Failure: ${errorText}` : "Failure: todo_write returned an error.",
4208
- "Fix the todo payload and call todo_write again before continuing.",
4291
+ payloadRejected
4292
+ ? "Correct the payload and call todo_write again before continuing."
4293
+ : "Do not change the payload solely because of this failure. The durable outcome is unknown; reconcile the session state before retrying or continuing.",
4209
4294
  "</system-reminder>",
4210
4295
  ].join("\n");
4211
- await this.sendCustomMessage(
4212
- {
4213
- customType: "todo-write-error-reminder",
4214
- content: reminderText,
4215
- display: false,
4216
- details: { toolName, errorText },
4217
- },
4218
- { deliverAs: "nextTurn" },
4219
- );
4296
+ try {
4297
+ await this.sendCustomMessage(
4298
+ {
4299
+ customType: "todo-write-error-reminder",
4300
+ content: reminderText,
4301
+ display: false,
4302
+ details: { toolName, errorText },
4303
+ },
4304
+ { deliverAs: "nextTurn" },
4305
+ );
4306
+ } catch (error) {
4307
+ logger.warn("Todo write error reminder persistence failed", {
4308
+ error: error instanceof Error ? error.message : String(error),
4309
+ });
4310
+ }
4220
4311
  }
4221
4312
  if (toolName === "checkpoint" && !isError) {
4222
4313
  const checkpointEntryId = this.sessionManager.getLeafId();
@@ -14228,6 +14319,12 @@ export class AgentSession {
14228
14319
  );
14229
14320
  }
14230
14321
  const trigger = classifyFallbackTrigger(transportFailure ?? { status: message.errorStatus });
14322
+ // A plain `forbidden` is terminal. Without this, the blanket
14323
+ // `transportFailure` admission below re-admits a committed forbidden
14324
+ // failure into retry policy: credential mutation is vetoed downstream, but
14325
+ // the chain would still retry and advance models on a request the caller is
14326
+ // simply not authorized to make.
14327
+ if (trigger.class === "auth" && trigger.authDisposition === "forbidden") return false;
14231
14328
  if (transportFailure) return true;
14232
14329
  if (
14233
14330
  trigger.class === "rate_limit" ||
@@ -14616,7 +14713,10 @@ export class AgentSession {
14616
14713
  message: AssistantMessage,
14617
14714
  allowLegacyUsageLimit: boolean,
14618
14715
  transportFailure?: TransportFailureFacts,
14619
- ): { class: FallbackTriggerClass; retryAfterMs?: number } | undefined {
14716
+ // `authDisposition` is carried through from `classifyFallbackTrigger`, whose
14717
+ // result is returned verbatim below. Narrowing it away here would silently
14718
+ // disable the terminal-403 guard in `#markFailedCredential`.
14719
+ ): { class: FallbackTriggerClass; retryAfterMs?: number; authDisposition?: AuthDisposition } | undefined {
14620
14720
  if (classifyContextOverflow(message, transportFailure, this.model?.contextWindow ?? 0)) return undefined;
14621
14721
  const transport = classifyFallbackTrigger(transportFailure ?? { status: message.errorStatus });
14622
14722
  if (transport.class !== "other") return transport;
@@ -14723,25 +14823,60 @@ export class AgentSession {
14723
14823
  return `Model fallback chain exhausted; models tried: ${tried}; models skipped: ${skipped}`;
14724
14824
  }
14725
14825
 
14726
- async #markFailedCredential(trigger: { class: FallbackTriggerClass; retryAfterMs?: number }): Promise<boolean> {
14826
+ /**
14827
+ * Marks the credential that just failed and reports whether the session
14828
+ * actually moved to a DIFFERENT stored credential.
14829
+ *
14830
+ * Three invariants this enforces, in order:
14831
+ *
14832
+ * 1. **Pin guard, first and for every trigger class.** A pinned credential
14833
+ * (`--api-key` or `--credential`) must never be mutated or rotated away
14834
+ * from. Both overrides are consulted: they live in separate maps, so
14835
+ * checking only `hasRuntimeApiKey` would silently rotate a `--credential`
14836
+ * pin. Previously the guard existed only on the quota path, so the `auth`
14837
+ * path invalidated pinned credentials outright.
14838
+ * 2. **A terminal `forbidden` never mutates credential state.** Rotation
14839
+ * would hide an authorization defect and cycle through healthy rows.
14840
+ * 3. **Distinct-row proof in BOTH branches.** `invalidateCredentialMatching`
14841
+ * reports "I matched and blocked a row", which is not the same as "the
14842
+ * session now uses a different credential" — with a single-row pool it is
14843
+ * true while nothing rotated. Both branches therefore re-resolve and
14844
+ * require the active key to have actually changed.
14845
+ */
14846
+ async #markFailedCredential(trigger: {
14847
+ class: FallbackTriggerClass;
14848
+ retryAfterMs?: number;
14849
+ authDisposition?: AuthDisposition;
14850
+ }): Promise<boolean> {
14727
14851
  if (!this.model || (trigger.class !== "auth" && trigger.class !== "quota" && trigger.class !== "rate_limit")) {
14728
14852
  return false;
14729
14853
  }
14854
+ // (2) Terminal forbidden: no credential state may change.
14855
+ if (trigger.class === "auth" && trigger.authDisposition === "forbidden") return false;
14856
+
14730
14857
  const authStorage = this.#modelRegistry.authStorage;
14858
+ const provider = this.model.provider;
14859
+ // (1) Pin guard, before any mutation and for every branch.
14860
+ if (authStorage.hasRuntimeApiKey(provider) || authStorage.hasRuntimeCredentialSelector(provider)) return false;
14861
+
14731
14862
  const providerAffinitySessionId = this.agent.providerSessionId ?? this.agent.sessionId ?? this.sessionId;
14863
+ const activeApiKey = await this.#modelRegistry.getApiKey(this.model, providerAffinitySessionId);
14864
+
14865
+ let mutated: boolean;
14732
14866
  if (trigger.class === "auth") {
14733
- const apiKey = await this.#modelRegistry.getApiKey(this.model, providerAffinitySessionId);
14734
- if (!isAuthenticated(apiKey)) return false;
14735
- return authStorage.invalidateCredentialMatching(this.model.provider, apiKey, {
14867
+ if (!isAuthenticated(activeApiKey)) return false;
14868
+ mutated = await authStorage.invalidateCredentialMatching(provider, activeApiKey, {
14736
14869
  sessionId: providerAffinitySessionId,
14737
14870
  });
14871
+ } else {
14872
+ mutated = await authStorage.markUsageLimitReached(provider, providerAffinitySessionId, {
14873
+ retryAfterMs: trigger.retryAfterMs,
14874
+ });
14738
14875
  }
14739
- if (authStorage.hasRuntimeApiKey(this.model.provider)) return false;
14740
- const activeApiKey = await this.#modelRegistry.getApiKey(this.model, providerAffinitySessionId);
14741
- const rotated = await authStorage.markUsageLimitReached(this.model.provider, providerAffinitySessionId, {
14742
- retryAfterMs: trigger.retryAfterMs,
14743
- });
14744
- return rotated && (await this.#modelRegistry.getApiKey(this.model, providerAffinitySessionId)) !== activeApiKey;
14876
+ if (!mutated) return false;
14877
+
14878
+ // (3) Distinct-row proof.
14879
+ return (await this.#modelRegistry.getApiKey(this.model, providerAffinitySessionId)) !== activeApiKey;
14745
14880
  }
14746
14881
 
14747
14882
  /** Handle retryable errors with exponential backoff. */
@@ -14836,8 +14971,15 @@ export class AgentSession {
14836
14971
  credentialRotated = await this.#markFailedCredential(trigger);
14837
14972
  }
14838
14973
  if (credentialRotated) {
14839
- if (managedFallback) controller.restorePreviousEntryForRetry();
14840
- outcome = "retry";
14974
+ // A rotation only becomes a same-model retry if the controller can
14975
+ // actually be rewound. `restorePreviousEntryForRetry()` refuses once an
14976
+ // entry's restore budget is consumed or attempts are exhausted; ignoring
14977
+ // that would force `outcome = "retry"` while `activeIndex` stays on the
14978
+ // next entry and `this.model` stays on the previous one — splitting
14979
+ // attempt attribution, exhaustion, and sticky selection across two models.
14980
+ if (!managedFallback || controller.restorePreviousEntryForRetry()) {
14981
+ outcome = "retry";
14982
+ }
14841
14983
  }
14842
14984
  if (outcome === "exhausted") {
14843
14985
  if (managedFallback) {