@gajae-code/coding-agent 0.6.5 → 0.7.1

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 (135) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/dist/types/async/job-manager.d.ts +3 -1
  3. package/dist/types/cli/daemon-cli.d.ts +25 -0
  4. package/dist/types/cli/notify-cli.d.ts +23 -0
  5. package/dist/types/cli/setup-cli.d.ts +20 -1
  6. package/dist/types/commands/daemon.d.ts +41 -0
  7. package/dist/types/commands/notify.d.ts +41 -0
  8. package/dist/types/config/model-profile-activation.d.ts +12 -0
  9. package/dist/types/config/model-profiles.d.ts +2 -1
  10. package/dist/types/config/model-registry.d.ts +3 -3
  11. package/dist/types/config/models-config-schema.d.ts +5 -0
  12. package/dist/types/config/settings-schema.d.ts +38 -0
  13. package/dist/types/coordinator/contract.d.ts +1 -1
  14. package/dist/types/daemon/builtin.d.ts +20 -0
  15. package/dist/types/daemon/control-types.d.ts +57 -0
  16. package/dist/types/daemon/runtime.d.ts +25 -0
  17. package/dist/types/extensibility/extensions/types.d.ts +8 -0
  18. package/dist/types/gjc-runtime/launch-tmux.d.ts +1 -0
  19. package/dist/types/gjc-runtime/state-writer.d.ts +2 -0
  20. package/dist/types/gjc-runtime/tmux-common.d.ts +3 -0
  21. package/dist/types/gjc-runtime/tmux-sessions.d.ts +2 -0
  22. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +15 -0
  23. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +14 -0
  24. package/dist/types/modes/components/oauth-selector.d.ts +2 -0
  25. package/dist/types/modes/controllers/selector-controller.d.ts +2 -2
  26. package/dist/types/modes/interactive-mode.d.ts +1 -1
  27. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +10 -0
  28. package/dist/types/modes/types.d.ts +7 -1
  29. package/dist/types/notifications/config-commands.d.ts +26 -0
  30. package/dist/types/notifications/config.d.ts +61 -0
  31. package/dist/types/notifications/helpers.d.ts +55 -0
  32. package/dist/types/notifications/html-format.d.ts +62 -0
  33. package/dist/types/notifications/index.d.ts +28 -0
  34. package/dist/types/notifications/rate-limit-pool.d.ts +93 -0
  35. package/dist/types/notifications/telegram-cli.d.ts +19 -0
  36. package/dist/types/notifications/telegram-daemon-cli.d.ts +11 -0
  37. package/dist/types/notifications/telegram-daemon-control.d.ts +56 -0
  38. package/dist/types/notifications/telegram-daemon.d.ts +276 -0
  39. package/dist/types/notifications/telegram-reference.d.ts +111 -0
  40. package/dist/types/notifications/threaded-inbound.d.ts +58 -0
  41. package/dist/types/notifications/threaded-render.d.ts +66 -0
  42. package/dist/types/notifications/topic-registry.d.ts +67 -0
  43. package/dist/types/rlm/index.d.ts +12 -0
  44. package/dist/types/session/agent-session.d.ts +39 -2
  45. package/dist/types/session/auth-storage.d.ts +1 -1
  46. package/dist/types/setup/credential-auto-import.d.ts +63 -0
  47. package/dist/types/setup/credential-import.d.ts +3 -0
  48. package/dist/types/setup/host-plugin-setup.d.ts +39 -0
  49. package/dist/types/tools/ask-answer-registry.d.ts +13 -0
  50. package/dist/types/tools/index.d.ts +18 -0
  51. package/dist/types/tools/subagent.d.ts +3 -0
  52. package/package.json +7 -7
  53. package/scripts/build-binary.ts +3 -0
  54. package/src/async/job-manager.ts +5 -1
  55. package/src/cli/daemon-cli.ts +122 -0
  56. package/src/cli/notify-cli.ts +274 -0
  57. package/src/cli/setup-cli.ts +173 -84
  58. package/src/cli.ts +3 -3
  59. package/src/commands/daemon.ts +47 -0
  60. package/src/commands/notify.ts +61 -0
  61. package/src/commands/setup.ts +11 -1
  62. package/src/config/model-profile-activation.ts +74 -5
  63. package/src/config/model-profiles.ts +7 -4
  64. package/src/config/model-registry.ts +6 -3
  65. package/src/config/models-config-schema.ts +1 -1
  66. package/src/config/settings-schema.ts +29 -0
  67. package/src/coordinator/contract.ts +3 -0
  68. package/src/coordinator-mcp/server.ts +270 -1
  69. package/src/daemon/builtin.ts +46 -0
  70. package/src/daemon/control-types.ts +65 -0
  71. package/src/daemon/runtime.ts +51 -0
  72. package/src/defaults/gjc/skills/ultragoal/SKILL.md +16 -0
  73. package/src/edit/modes/replace.ts +1 -1
  74. package/src/extensibility/extensions/runner.ts +4 -0
  75. package/src/extensibility/extensions/types.ts +8 -0
  76. package/src/gjc-runtime/deep-interview-recorder.ts +12 -4
  77. package/src/gjc-runtime/launch-tmux.ts +10 -2
  78. package/src/gjc-runtime/state-runtime.ts +18 -4
  79. package/src/gjc-runtime/state-writer.ts +8 -8
  80. package/src/gjc-runtime/tmux-common.ts +8 -0
  81. package/src/gjc-runtime/tmux-sessions.ts +8 -1
  82. package/src/gjc-runtime/ultragoal-guard.ts +57 -2
  83. package/src/gjc-runtime/ultragoal-runtime.ts +105 -19
  84. package/src/gjc-runtime/workflow-manifest.generated.json +27 -2
  85. package/src/gjc-runtime/workflow-manifest.ts +11 -1
  86. package/src/goals/tools/goal-tool.ts +11 -2
  87. package/src/hashline/hash.ts +1 -1
  88. package/src/internal-urls/docs-index.generated.ts +9 -7
  89. package/src/main.ts +30 -0
  90. package/src/modes/acp/acp-event-mapper.ts +1 -0
  91. package/src/modes/components/hook-editor.ts +7 -2
  92. package/src/modes/components/oauth-selector.ts +19 -0
  93. package/src/modes/controllers/event-controller.ts +20 -0
  94. package/src/modes/controllers/selector-controller.ts +80 -17
  95. package/src/modes/interactive-mode.ts +6 -2
  96. package/src/modes/runtime-init.ts +1 -0
  97. package/src/modes/shared/agent-wire/event-contract.ts +1 -0
  98. package/src/modes/shared/agent-wire/event-envelope.ts +1 -0
  99. package/src/modes/shared/agent-wire/event-observation.ts +16 -0
  100. package/src/modes/shared/agent-wire/unattended-session.ts +22 -0
  101. package/src/modes/types.ts +7 -1
  102. package/src/modes/utils/ui-helpers.ts +23 -0
  103. package/src/notifications/config-commands.ts +50 -0
  104. package/src/notifications/config.ts +107 -0
  105. package/src/notifications/helpers.ts +135 -0
  106. package/src/notifications/html-format.ts +389 -0
  107. package/src/notifications/index.ts +700 -0
  108. package/src/notifications/rate-limit-pool.ts +179 -0
  109. package/src/notifications/telegram-cli.ts +194 -0
  110. package/src/notifications/telegram-daemon-cli.ts +74 -0
  111. package/src/notifications/telegram-daemon-control.ts +370 -0
  112. package/src/notifications/telegram-daemon.ts +1370 -0
  113. package/src/notifications/telegram-reference.ts +335 -0
  114. package/src/notifications/threaded-inbound.ts +80 -0
  115. package/src/notifications/threaded-render.ts +155 -0
  116. package/src/notifications/topic-registry.ts +133 -0
  117. package/src/rlm/index.ts +19 -0
  118. package/src/sdk.ts +16 -0
  119. package/src/session/agent-session.ts +113 -3
  120. package/src/session/auth-storage.ts +3 -0
  121. package/src/session/session-dump-format.ts +43 -2
  122. package/src/session/session-manager.ts +39 -5
  123. package/src/setup/credential-auto-import.ts +258 -0
  124. package/src/setup/credential-import.ts +17 -0
  125. package/src/setup/hermes/templates/operator-instructions.v1.md +10 -0
  126. package/src/setup/host-plugin-setup.ts +142 -0
  127. package/src/slash-commands/builtin-registry.ts +4 -1
  128. package/src/task/executor.ts +5 -1
  129. package/src/tools/ask-answer-registry.ts +25 -0
  130. package/src/tools/ask.ts +77 -6
  131. package/src/tools/image-gen.ts +5 -8
  132. package/src/tools/index.ts +19 -0
  133. package/src/tools/inspect-image.ts +16 -11
  134. package/src/tools/subagent-render.ts +7 -0
  135. package/src/tools/subagent.ts +38 -7
package/src/rlm/index.ts CHANGED
@@ -240,8 +240,27 @@ async function writeRlmMetadata(input: {
240
240
  }
241
241
  }
242
242
 
243
+ /**
244
+ * RLM artifacts are scoped under a GJC session directory and resolving their
245
+ * paths is a *write* (it must pick a concrete session). When `gjc rlm` runs
246
+ * standalone — no parent agent, no `GJC_SESSION_ID` in the environment — there is
247
+ * no session to resolve and `resolveGjcSessionForWrite` throws
248
+ * `missing_for_write`. Establish a dedicated GJC session id in that case and pin
249
+ * it into the environment so artifact-path resolution, the per-session activity
250
+ * marker, and the child agent's workflow state all share one writable session.
251
+ *
252
+ * Returns the resolved (existing or freshly generated) GJC session id.
253
+ */
254
+ export function ensureRlmGjcSessionId(): string {
255
+ const existing = resolveSessionIdFromSources({ envSessionId: process.env.GJC_SESSION_ID })?.gjcSessionId;
256
+ if (existing) return existing;
257
+ const generated = `rlm-${generateRlmSessionId()}`;
258
+ process.env.GJC_SESSION_ID = generated;
259
+ return generated;
260
+ }
243
261
  export async function runRlmCommand(argv: string[]): Promise<void> {
244
262
  const cwd = getProjectDir();
263
+ ensureRlmGjcSessionId();
245
264
  const { dataPath, resumeSessionId, minSuccessfulRuns, rest } = extractRlmFlags(argv);
246
265
  const dataContext = await loadRlmDataContext(cwd, dataPath);
247
266
 
package/src/sdk.ts CHANGED
@@ -79,6 +79,12 @@ import type { HindsightSessionState } from "./hindsight/state";
79
79
  import { LocalProtocolHandler, type LocalProtocolOptions } from "./internal-urls";
80
80
  import { LSP_STARTUP_EVENT_CHANNEL, type LspStartupEvent } from "./lsp/startup-events";
81
81
  import { resolveMemoryBackend } from "./memory-backend";
82
+ import { createNotificationsExtension } from "./notifications";
83
+ import {
84
+ getNotificationConfig,
85
+ type NotificationConfig,
86
+ shouldRegisterNotificationsExtension,
87
+ } from "./notifications/config";
82
88
  import asyncResultTemplate from "./prompts/tools/async-result.md" with { type: "text" };
83
89
  import { AgentRegistry, MAIN_AGENT_ID } from "./registry/agent-registry";
84
90
  import { MCPManager } from "./runtime-mcp";
@@ -1238,6 +1244,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1238
1244
  getPlanModeState: () => session?.getPlanModeState(),
1239
1245
  getGoalModeState: () => session?.getGoalModeState(),
1240
1246
  getWorkflowGateEmitter: () => session?.getWorkflowGateEmitter(),
1247
+ getAskAnswerSource: () => session?.getAskAnswerSource(),
1241
1248
  getGoalRuntime: () => session?.goalRuntime,
1242
1249
  getClientBridge: () => session?.clientBridge,
1243
1250
  getCompactContext: () => session.formatCompactContext(),
@@ -1386,6 +1393,15 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1386
1393
  if (customTools.length > 0) {
1387
1394
  inlineExtensions.push(createCustomToolsExtension(customTools));
1388
1395
  }
1396
+ let notificationCfg: NotificationConfig | undefined;
1397
+ try {
1398
+ notificationCfg = getNotificationConfig(Settings.instance);
1399
+ } catch {
1400
+ notificationCfg = undefined;
1401
+ }
1402
+ if (shouldRegisterNotificationsExtension({ env: process.env, cfg: notificationCfg })) {
1403
+ inlineExtensions.push(createNotificationsExtension);
1404
+ }
1389
1405
 
1390
1406
  // Extension/module discovery is quarantined; retain only the private
1391
1407
  // runtime needed for bundled product extensions, explicitly supplied SDK
@@ -238,8 +238,9 @@ import {
238
238
  type DiscoverableTool,
239
239
  type DiscoverableToolSearchIndex,
240
240
  } from "../tool-discovery/tool-index";
241
- import type { ToolSession } from "../tools";
241
+ import type { AskAnswerSource, ToolSession } from "../tools";
242
242
  import { AskTool } from "../tools/ask";
243
+ import { getAskAnswerSource as getAskAnswerSourceFromRegistry } from "../tools/ask-answer-registry";
243
244
  import { assertEditableFile } from "../tools/auto-generated-guard";
244
245
  import { releaseTabsForOwner } from "../tools/browser/tab-supervisor";
245
246
  import type { CheckpointState } from "../tools/checkpoint";
@@ -313,6 +314,7 @@ export type AgentSessionEvent =
313
314
  | { type: "todo_reminder"; todos: TodoItem[]; attempt: number; maxAttempts: number }
314
315
  | { type: "todo_auto_clear" }
315
316
  | { type: "irc_message"; message: CustomMessage }
317
+ | { type: "subagent_steer_message"; message: CustomMessage }
316
318
  | { type: "notice"; level: "info" | "warning" | "error"; message: string; source?: string }
317
319
  | { type: "thinking_level_changed"; thinkingLevel: ThinkingLevel | undefined }
318
320
  | { type: "goal_updated"; goal: Goal | null; state?: GoalModeState };
@@ -4385,6 +4387,10 @@ export class AgentSession {
4385
4387
  return this.#workflowGateEmitter;
4386
4388
  }
4387
4389
 
4390
+ getAskAnswerSource(): AskAnswerSource | undefined {
4391
+ return getAskAnswerSourceFromRegistry(this.sessionId);
4392
+ }
4393
+
4388
4394
  setWorkflowGateEmitter(emitter: WorkflowGateEmitter | undefined): void {
4389
4395
  this.#workflowGateEmitter = emitter;
4390
4396
  if (emitter) {
@@ -5453,6 +5459,16 @@ export class AgentSession {
5453
5459
  return;
5454
5460
  }
5455
5461
 
5462
+ // No explicit delivery mode: only a live stream makes prompt() throw
5463
+ // AgentBusyError, so queue the message as steering while streaming.
5464
+ // Compaction is intentionally NOT diverted here: prompt() handles an
5465
+ // in-flight compaction internally, and #queueSteer would otherwise park
5466
+ // the message in the steering queue with no turn to consume it.
5467
+ if (this.isStreaming) {
5468
+ await this.#queueSteer(text, images);
5469
+ return;
5470
+ }
5471
+
5456
5472
  // Use prompt() with expandPromptTemplates: false to skip command handling and template expansion
5457
5473
  await this.prompt(text, {
5458
5474
  expandPromptTemplates: false,
@@ -5856,12 +5872,46 @@ export class AgentSession {
5856
5872
  return this.#activeModelProfile;
5857
5873
  }
5858
5874
 
5875
+ /**
5876
+ * The model selector ("provider/id") that resume restores as the session
5877
+ * default — the latest session-log `model_change` with role="default".
5878
+ * Model-profile activation snapshots this before mutating the session so a
5879
+ * failed-activation rollback can restore the pre-activation resume default
5880
+ * instead of promoting a transient runtime model to the resume default.
5881
+ */
5882
+ getSessionDefaultModelSelector(): string | undefined {
5883
+ return this.sessionManager.buildSessionContext().models.default;
5884
+ }
5885
+
5886
+ /**
5887
+ * Re-assert the session resume default ("provider/id") in the session log
5888
+ * WITHOUT touching the live runtime model. Appends a `model_change` with
5889
+ * role="default"; never writes to global settings (apply-for-this-session
5890
+ * semantics). Used by model-profile activation rollback to neutralize the
5891
+ * profile main model the failed activation already recorded as the default.
5892
+ */
5893
+ recordResumeDefaultModel(selector: string): void {
5894
+ this.sessionManager.appendModelChange(selector, "default");
5895
+ }
5896
+
5859
5897
  /**
5860
5898
  * Set model temporarily (for this session only).
5861
5899
  * Validates API key, saves to session log but NOT to settings.
5900
+ *
5901
+ * The change is recorded in the session log as `role: "temporary"` by
5902
+ * default, which means it is NOT restored as the session default on resume —
5903
+ * transient retry/fallback/context-promotion/plan switches must not clobber
5904
+ * the user's explicit pick (issue #849). Model-profile activation passes
5905
+ * `persistAsSessionDefault: true` so the profile's main model becomes the
5906
+ * session default and survives resume, while still not being written to
5907
+ * global settings (new sessions keep the global default).
5862
5908
  * @throws Error if no API key available for the model
5863
5909
  */
5864
- async setModelTemporary(model: Model, thinkingLevel?: ThinkingLevel): Promise<void> {
5910
+ async setModelTemporary(
5911
+ model: Model,
5912
+ thinkingLevel?: ThinkingLevel,
5913
+ options?: { persistAsSessionDefault?: boolean },
5914
+ ): Promise<void> {
5865
5915
  const previousEditMode = this.#resolveActiveEditMode();
5866
5916
  const apiKey = await this.#modelRegistry.getApiKey(model, this.sessionId);
5867
5917
  if (!apiKey) {
@@ -5870,7 +5920,10 @@ export class AgentSession {
5870
5920
 
5871
5921
  this.#clearActiveRetryFallback();
5872
5922
  this.#setModelWithProviderSessionReset(model);
5873
- this.sessionManager.appendModelChange(`${model.provider}/${model.id}`, "temporary");
5923
+ this.sessionManager.appendModelChange(
5924
+ `${model.provider}/${model.id}`,
5925
+ options?.persistAsSessionDefault ? "default" : "temporary",
5926
+ );
5874
5927
  this.settings.getStorage()?.recordModelUsage(`${model.provider}/${model.id}`);
5875
5928
 
5876
5929
  // Apply explicit thinking level if given; otherwise prefer the model's
@@ -9089,6 +9142,63 @@ export class AgentSession {
9089
9142
  void this.#emitSessionEvent({ type: "irc_message", message: record });
9090
9143
  }
9091
9144
 
9145
+ emitSubagentSteerObservation(args: { from: string; to: string; body: string; timestamp?: number }): void {
9146
+ const timestamp = args.timestamp ?? Date.now();
9147
+ const observationId = crypto.randomUUID();
9148
+ const message: CustomMessage = {
9149
+ role: "custom",
9150
+ customType: "subagent:steer",
9151
+ content: `[Steer \`${args.from}\` ⇨ \`${args.to}\` (queued)]\n\n${args.body}`,
9152
+ display: true,
9153
+ details: { observationId, from: args.from, to: args.to, body: args.body, state: "queued" },
9154
+ attribution: "agent",
9155
+ timestamp,
9156
+ };
9157
+ void this.#emitSessionEvent({ type: "subagent_steer_message", message });
9158
+ this.#forwardSubagentSteerRelayToMain({
9159
+ from: args.from,
9160
+ to: args.to,
9161
+ body: args.body,
9162
+ observationId,
9163
+ timestamp,
9164
+ });
9165
+ }
9166
+
9167
+ #forwardSubagentSteerRelayToMain(args: {
9168
+ from: string;
9169
+ to: string;
9170
+ body: string;
9171
+ observationId: string;
9172
+ timestamp: number;
9173
+ }): void {
9174
+ const registry = this.#agentRegistry;
9175
+ if (!registry) return;
9176
+ if (this.#agentId === MAIN_AGENT_ID) return;
9177
+ const mainRef = registry.get(MAIN_AGENT_ID);
9178
+ const mainSession = mainRef?.session;
9179
+ if (!mainSession || mainSession === this) return;
9180
+ const record: CustomMessage = {
9181
+ role: "custom",
9182
+ customType: "subagent:steer:relay",
9183
+ content: `[Steer \`${args.from}\` ⇨ \`${args.to}\` (queued)]\n\n${args.body}`,
9184
+ display: true,
9185
+ details: {
9186
+ observationId: args.observationId,
9187
+ from: args.from,
9188
+ to: args.to,
9189
+ body: args.body,
9190
+ state: "queued",
9191
+ },
9192
+ attribution: "agent",
9193
+ timestamp: args.timestamp,
9194
+ };
9195
+ mainSession.emitSubagentSteerRelayObservation(record);
9196
+ }
9197
+
9198
+ emitSubagentSteerRelayObservation(record: CustomMessage): void {
9199
+ void this.#emitSessionEvent({ type: "subagent_steer_message", message: record });
9200
+ }
9201
+
9092
9202
  /**
9093
9203
  * Run a single ephemeral side-channel turn against this session's current
9094
9204
  * model + system prompt + history. No tools are used; the side request
@@ -7,6 +7,9 @@ export type {
7
7
  ApiKeyCredential,
8
8
  AuthCredential,
9
9
  AuthCredentialEntry,
10
+ AuthCredentialIfAbsentReason,
11
+ AuthCredentialIfAbsentResult,
12
+ AuthCredentialIfAbsentSnapshotResult,
10
13
  AuthCredentialStore,
11
14
  AuthStorageData,
12
15
  AuthStorageOptions,
@@ -47,13 +47,54 @@ function stripTypeBoxFields(obj: unknown): unknown {
47
47
  return obj;
48
48
  }
49
49
 
50
+ function escapeXmlAttribute(input: string): string {
51
+ return input.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
52
+ }
53
+
54
+ function escapeXmlText(input: string): string {
55
+ return input.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
56
+ }
57
+
58
+ function decodeCodePoint(hex: string): string {
59
+ const codePoint = Number.parseInt(hex, 16);
60
+ if (
61
+ !Number.isFinite(codePoint) ||
62
+ codePoint < 0x20 ||
63
+ (codePoint >= 0x7f && codePoint <= 0x9f) ||
64
+ (codePoint >= 0xd800 && codePoint <= 0xdfff)
65
+ ) {
66
+ return `\\u${hex}`;
67
+ }
68
+ return String.fromCharCode(codePoint);
69
+ }
70
+
71
+ function decodeUnicodeEscapeText(input: string): string {
72
+ return input
73
+ .replace(/\\\\u([0-9a-fA-F]{4})/g, (_match, hex: string) => decodeCodePoint(hex))
74
+ .replace(/\\u([0-9a-fA-F]{4})/g, (_match, hex: string) => decodeCodePoint(hex));
75
+ }
76
+
77
+ function formatParameterValue(value: unknown): string {
78
+ const raw = typeof value === "string" ? value : (JSON.stringify(value, null, "\t") ?? "null");
79
+ return escapeXmlText(decodeUnicodeEscapeText(raw));
80
+ }
81
+
50
82
  /** Serialize an object as XML parameter elements, one per key. */
51
83
  function formatArgsAsXml(args: Record<string, unknown>, indent = "\t"): string {
52
84
  const parts: string[] = [];
53
85
  for (const [key, value] of Object.entries(args)) {
54
86
  if (key === INTENT_FIELD) continue;
55
- const text = typeof value === "string" ? value : JSON.stringify(value);
56
- parts.push(`${indent}<parameter name="${key}">${text}</parameter>`);
87
+ const escapedKey = escapeXmlAttribute(key);
88
+ const text = formatParameterValue(value);
89
+ if (text.includes("\n")) {
90
+ const indentedText = text
91
+ .split("\n")
92
+ .map(line => `${indent}\t${line}`)
93
+ .join("\n");
94
+ parts.push(`${indent}<parameter name="${escapedKey}">\n${indentedText}\n${indent}</parameter>`);
95
+ } else {
96
+ parts.push(`${indent}<parameter name="${escapedKey}">${text}</parameter>`);
97
+ }
57
98
  }
58
99
  return parts.join("\n");
59
100
  }
@@ -28,6 +28,7 @@ import {
28
28
  toError,
29
29
  } from "@gajae-code/utils";
30
30
  import { writeTextAtomic } from "../gjc-runtime/state-writer";
31
+ import * as git from "../utils/git";
31
32
  import { ArtifactManager } from "./artifacts";
32
33
  import {
33
34
  type BlobPutResult,
@@ -791,6 +792,25 @@ function writeTerminalBreadcrumb(cwd: string, sessionFile: string): void {
791
792
  write.catch(() => {});
792
793
  }
793
794
 
795
+ /**
796
+ * Two paths belong to linked worktrees of the same repository when they share a
797
+ * git common dir but resolve to different git dirs (i.e. one is a `git worktree`
798
+ * of the other). `--worktree` sessions run from such a linked worktree, so a
799
+ * `--continue` from the main checkout should still resolve their breadcrumb.
800
+ */
801
+ function isLinkedWorktreePeer(a: string, b: string): boolean {
802
+ const ra = git.repo.resolveSync(a);
803
+ const rb = git.repo.resolveSync(b);
804
+ if (ra === null || rb === null) return false;
805
+ // Canonicalize: a worktree's commondir is stored as an absolute path that may
806
+ // differ from the main checkout only by a symlink prefix (e.g. macOS
807
+ // /tmp -> /private/tmp), so compare resolved-equivalent paths.
808
+ return (
809
+ resolveEquivalentPath(ra.commonDir) === resolveEquivalentPath(rb.commonDir) &&
810
+ resolveEquivalentPath(ra.gitDir) !== resolveEquivalentPath(rb.gitDir)
811
+ );
812
+ }
813
+
794
814
  /**
795
815
  * Read the terminal breadcrumb for the current terminal, scoped to a cwd.
796
816
  * Returns the session file path if it exists and matches the cwd, null otherwise.
@@ -808,8 +828,12 @@ async function readTerminalBreadcrumb(cwd: string): Promise<string | null> {
808
828
  const breadcrumbCwd = lines[0];
809
829
  const sessionFile = lines[1];
810
830
 
811
- // Only return if cwd matches (user might have cd'd)
812
- if (path.resolve(breadcrumbCwd) !== path.resolve(cwd)) return null;
831
+ // Honor the breadcrumb when the cwd matches, or when it points to a linked
832
+ // worktree of the same repository (e.g. a `--worktree` session resumed from
833
+ // the main checkout). A genuinely different project is still ignored.
834
+ if (path.resolve(breadcrumbCwd) !== path.resolve(cwd) && !isLinkedWorktreePeer(breadcrumbCwd, cwd)) {
835
+ return null;
836
+ }
813
837
 
814
838
  // Verify the session file still exists
815
839
  const stat = fs.statSync(sessionFile, { throwIfNoEntry: false });
@@ -4010,12 +4034,22 @@ export class SessionManager {
4010
4034
  // Prefer terminal-scoped breadcrumb (handles concurrent sessions correctly)
4011
4035
  const terminalSession = await readTerminalBreadcrumb(cwd);
4012
4036
  const mostRecent = terminalSession ?? (await findMostRecentSession(dir, storage));
4013
- const manager = new SessionManager(cwd, dir, true, storage);
4014
4037
  if (mostRecent) {
4038
+ // Adopt the resumed session's recorded cwd and its own directory. A
4039
+ // `--worktree` session lives in a linked worktree whose path differs from
4040
+ // the invocation cwd; binding the manager (and HUD) to `cwd` would leave
4041
+ // it on the main checkout instead of the worktree it was created in.
4042
+ const header = (await loadEntriesFromFile(mostRecent, storage)).find(e => e.type === "session") as
4043
+ | SessionHeader
4044
+ | undefined;
4045
+ const resumeCwd = header?.cwd || cwd;
4046
+ const resumeDir = sessionDir ?? path.resolve(mostRecent, "..");
4047
+ const manager = new SessionManager(resumeCwd, resumeDir, true, storage);
4015
4048
  await manager.#initSessionFile(mostRecent);
4016
- } else {
4017
- manager.#initNewSession();
4049
+ return manager;
4018
4050
  }
4051
+ const manager = new SessionManager(cwd, dir, true, storage);
4052
+ manager.#initNewSession();
4019
4053
  return manager;
4020
4054
  }
4021
4055
 
@@ -0,0 +1,258 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import type {
4
+ AuthCredential,
5
+ AuthCredentialIfAbsentReason,
6
+ AuthCredentialIfAbsentSnapshotResult,
7
+ AuthStorage,
8
+ } from "@gajae-code/ai";
9
+ import { getAgentDir, logger, VERSION } from "@gajae-code/utils";
10
+ import type { ModelRegistry } from "../config/model-registry";
11
+
12
+ import {
13
+ type CredentialDiscoveryResult,
14
+ type CredentialOrigin,
15
+ type DiscoveryOptions,
16
+ discoverExternalCredentials,
17
+ EXTERNAL_PROVIDER_LABELS,
18
+ type ExternalProvider,
19
+ filterAutoImportOAuthCredentials,
20
+ formatCredentialSummary,
21
+ type ImportableCredential,
22
+ } from "./credential-import";
23
+
24
+ export const CREDENTIAL_AUTO_IMPORT_ROTATION_WARNING =
25
+ "Refreshing in gjc may log out the Claude/Codex CLI because OAuth refresh tokens can rotate.";
26
+
27
+ export type CredentialAutoImportSourceLabel = "claude-code-file" | "claude-code-keychain" | "codex-file";
28
+ export type CredentialAutoImportTrigger = "startup" | "bare-login" | "setup-cli";
29
+
30
+ const CREDENTIAL_AUTO_IMPORT_STATE_FILENAME = "credential-auto-import-state.json";
31
+
32
+ interface CredentialAutoImportStateFile {
33
+ lastImportVersion?: unknown;
34
+ }
35
+
36
+ export function getCredentialAutoImportStatePath(agentDir: string = getAgentDir()): string {
37
+ return path.join(agentDir, CREDENTIAL_AUTO_IMPORT_STATE_FILENAME);
38
+ }
39
+
40
+ export async function readCredentialImportMarker(agentDir?: string): Promise<string | undefined> {
41
+ try {
42
+ const raw = await fs.readFile(getCredentialAutoImportStatePath(agentDir), "utf-8");
43
+ const parsed = JSON.parse(raw) as CredentialAutoImportStateFile;
44
+ return typeof parsed.lastImportVersion === "string" ? parsed.lastImportVersion : undefined;
45
+ } catch {
46
+ return undefined;
47
+ }
48
+ }
49
+
50
+ export async function writeCredentialImportMarker(version: string, agentDir?: string): Promise<boolean> {
51
+ try {
52
+ const statePath = getCredentialAutoImportStatePath(agentDir);
53
+ await fs.mkdir(path.dirname(statePath), { recursive: true });
54
+ await fs.writeFile(statePath, `${JSON.stringify({ lastImportVersion: version })}\n`);
55
+ return true;
56
+ } catch (error: unknown) {
57
+ logger.warn("Failed to persist credential auto-import state", { error });
58
+ return false;
59
+ }
60
+ }
61
+
62
+ export enum CredentialAutoImportFailureClass {
63
+ DiscoveryUnavailable = "discovery-unavailable",
64
+ SourceUnreadable = "source-unreadable",
65
+ SourceMalformed = "source-malformed",
66
+ KeychainDenied = "keychain-denied",
67
+ WriteInvalid = "write-invalid",
68
+ WriteConflict = "write-conflict",
69
+ BrokerUnavailable = "broker-unavailable",
70
+ BrokerUnsupported = "broker-unsupported",
71
+ Unknown = "unknown",
72
+ }
73
+
74
+ export interface CredentialAutoImportSkipped {
75
+ credential: ImportableCredential;
76
+ reason: AuthCredentialIfAbsentReason;
77
+ entries: AuthCredentialIfAbsentSnapshotResult["entries"];
78
+ }
79
+
80
+ export interface CredentialAutoImportFailure {
81
+ credential?: ImportableCredential;
82
+ origin?: CredentialOrigin;
83
+ source?: string;
84
+ failureClass: CredentialAutoImportFailureClass;
85
+ }
86
+
87
+ export interface CredentialAutoImportResult {
88
+ imported: ImportableCredential[];
89
+ skipped: CredentialAutoImportSkipped[];
90
+ failures: CredentialAutoImportFailure[];
91
+ discovered: boolean;
92
+ discovery?: CredentialDiscoveryResult;
93
+ globalDiscoveryFailure?: CredentialAutoImportFailure;
94
+ }
95
+
96
+ export type CredentialAutoImportAuthStorage = Pick<AuthStorage, "importCredentialIfAbsent">;
97
+
98
+ export interface CredentialAutoImportOptions {
99
+ authStorage: CredentialAutoImportAuthStorage;
100
+ discover?: (options?: DiscoveryOptions) => Promise<CredentialDiscoveryResult>;
101
+ discoveryOptions?: DiscoveryOptions;
102
+ trigger: CredentialAutoImportTrigger;
103
+ sourceLabel?: CredentialAutoImportSourceLabel;
104
+ }
105
+
106
+ function classifyDiscoverySkip(reason: string, origin: CredentialOrigin): CredentialAutoImportFailureClass {
107
+ const lower = reason.toLowerCase();
108
+ if (
109
+ origin === "claude-code-keychain" &&
110
+ (lower.includes("eacces") || lower.includes("eperm") || lower.includes("denied"))
111
+ ) {
112
+ return CredentialAutoImportFailureClass.KeychainDenied;
113
+ }
114
+ if (lower.includes("malformed")) return CredentialAutoImportFailureClass.SourceMalformed;
115
+ if (lower.includes("unreadable")) return CredentialAutoImportFailureClass.SourceUnreadable;
116
+ return CredentialAutoImportFailureClass.Unknown;
117
+ }
118
+
119
+ function classifyWriteFailure(error: unknown): CredentialAutoImportFailureClass {
120
+ const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
121
+ if (message.includes("invalid")) return CredentialAutoImportFailureClass.WriteInvalid;
122
+ if (message.includes("conflict") || message.includes("constraint"))
123
+ return CredentialAutoImportFailureClass.WriteConflict;
124
+ if (
125
+ message.includes("broker") &&
126
+ (message.includes("unsupported") || message.includes("404") || message.includes("501"))
127
+ ) {
128
+ return CredentialAutoImportFailureClass.BrokerUnsupported;
129
+ }
130
+ if (message.includes("broker") || message.includes("fetch") || message.includes("network")) {
131
+ return CredentialAutoImportFailureClass.BrokerUnavailable;
132
+ }
133
+ return CredentialAutoImportFailureClass.Unknown;
134
+ }
135
+
136
+ export async function runExternalCredentialAutoImport({
137
+ authStorage,
138
+ discover = discoverExternalCredentials,
139
+ discoveryOptions,
140
+ }: CredentialAutoImportOptions): Promise<CredentialAutoImportResult> {
141
+ let discovery: CredentialDiscoveryResult;
142
+ try {
143
+ discovery = await discover(discoveryOptions);
144
+ } catch {
145
+ const globalDiscoveryFailure = { failureClass: CredentialAutoImportFailureClass.DiscoveryUnavailable };
146
+ return {
147
+ imported: [],
148
+ skipped: [],
149
+ failures: [globalDiscoveryFailure],
150
+ discovered: false,
151
+ globalDiscoveryFailure,
152
+ };
153
+ }
154
+
155
+ const candidates = filterAutoImportOAuthCredentials(discovery.importable);
156
+ const failures: CredentialAutoImportFailure[] = discovery.skipped.map(skip => ({
157
+ origin: skip.origin,
158
+ source: skip.source,
159
+ failureClass: classifyDiscoverySkip(skip.reason, skip.origin),
160
+ }));
161
+ const imported: ImportableCredential[] = [];
162
+ const skipped: CredentialAutoImportSkipped[] = [];
163
+ const importIfAbsent = authStorage.importCredentialIfAbsent;
164
+
165
+ for (const credential of candidates) {
166
+ try {
167
+ const outcome = await importIfAbsent.call(
168
+ authStorage,
169
+ credential.provider,
170
+ credential.credential as AuthCredential,
171
+ );
172
+ if (outcome.inserted === true) {
173
+ imported.push(credential);
174
+ } else {
175
+ skipped.push({ credential, reason: outcome.reason, entries: outcome.entries });
176
+ }
177
+ } catch (error) {
178
+ failures.push({ credential, failureClass: classifyWriteFailure(error) });
179
+ }
180
+ }
181
+
182
+ return { imported, skipped, failures, discovered: true, discovery };
183
+ }
184
+
185
+ export function buildCredentialAutoImportNotice(
186
+ result: Pick<CredentialAutoImportResult, "imported">,
187
+ ): string | undefined {
188
+ if (result.imported.length === 0) return undefined;
189
+ const providers = [
190
+ ...new Set(result.imported.map(c => EXTERNAL_PROVIDER_LABELS[c.provider as ExternalProvider] ?? c.provider)),
191
+ ];
192
+ const success = `Imported ${result.imported.length} external OAuth credential(s) into gjc: ${providers.join(", ")}.`;
193
+ return `${success}\n${CREDENTIAL_AUTO_IMPORT_ROTATION_WARNING}`;
194
+ }
195
+
196
+ export function formatCredentialAutoImportResult(result: CredentialAutoImportResult): string[] {
197
+ const lines: string[] = [];
198
+ for (const credential of result.imported) lines.push(`imported ${formatCredentialSummary(credential)}`);
199
+ for (const skip of result.skipped) lines.push(`skipped ${skip.credential.source}: ${skip.reason}`);
200
+ for (const failure of result.failures) {
201
+ const label = failure.credential?.source ?? failure.source ?? "external credential discovery";
202
+ lines.push(`failed ${label}: ${failure.failureClass}`);
203
+ }
204
+ return lines;
205
+ }
206
+
207
+ export interface CredentialImportMarkerStore {
208
+ read: () => Promise<string | undefined> | string | undefined;
209
+ write: (version: string) => Promise<boolean> | boolean;
210
+ }
211
+
212
+ export interface StartupCredentialAutoImportOptions {
213
+ authStorage: CredentialAutoImportOptions["authStorage"];
214
+ modelRegistry: Pick<ModelRegistry, "refresh">;
215
+ discover?: CredentialAutoImportOptions["discover"];
216
+ version?: string;
217
+ agentDir?: string;
218
+ markerStore?: CredentialImportMarkerStore;
219
+ }
220
+
221
+ export async function runStartupCredentialAutoImportIfNeeded({
222
+ authStorage: activeAuthStorage,
223
+ modelRegistry: activeModelRegistry,
224
+ discover,
225
+ version = VERSION,
226
+ agentDir,
227
+ markerStore,
228
+ }: StartupCredentialAutoImportOptions): Promise<string | undefined> {
229
+ const store = markerStore ?? {
230
+ read: () => readCredentialImportMarker(agentDir),
231
+ write: (nextVersion: string) => writeCredentialImportMarker(nextVersion, agentDir),
232
+ };
233
+ const lastVersion = await store.read();
234
+ if (lastVersion === version) {
235
+ // Steady state: user already completed this version's auto-import gate. Skip all file/Keychain reads.
236
+ return undefined;
237
+ }
238
+
239
+ const result = await runExternalCredentialAutoImport({
240
+ authStorage: activeAuthStorage,
241
+ discover,
242
+ trigger: "startup",
243
+ });
244
+ if (!result.discovered) {
245
+ return undefined;
246
+ }
247
+
248
+ const candidates = filterAutoImportOAuthCredentials(result.discovery?.importable ?? []);
249
+ if (candidates.length > 0 && result.imported.length === 0 && result.skipped.length === 0) {
250
+ return undefined;
251
+ }
252
+ await store.write(version);
253
+
254
+ if (result.imported.length > 0) {
255
+ await activeModelRegistry.refresh("offline");
256
+ }
257
+ return buildCredentialAutoImportNotice(result);
258
+ }
@@ -25,6 +25,11 @@ export type ExternalProvider = "anthropic" | "openai-codex";
25
25
  /** Where a discovered credential came from. */
26
26
  export type CredentialOrigin = "claude-code-file" | "claude-code-keychain" | "codex-file";
27
27
 
28
+ export const AUTO_IMPORT_OAUTH_PROVIDER_ORIGINS: Record<ExternalProvider, ReadonlySet<CredentialOrigin>> = {
29
+ anthropic: new Set<CredentialOrigin>(["claude-code-file", "claude-code-keychain"]),
30
+ "openai-codex": new Set<CredentialOrigin>(["codex-file"]),
31
+ };
32
+
28
33
  /** Human labels for providers, used in redacted summaries. */
29
34
  export const EXTERNAL_PROVIDER_LABELS: Record<ExternalProvider, string> = {
30
35
  anthropic: "Claude (Anthropic)",
@@ -408,6 +413,18 @@ export function formatDiscoverySummary(result: CredentialDiscoveryResult): strin
408
413
  return lines;
409
414
  }
410
415
 
416
+ export function isAutoImportOAuthCredential(credential: ImportableCredential): boolean {
417
+ return (
418
+ AUTO_IMPORT_OAUTH_PROVIDER_ORIGINS[credential.provider]?.has(credential.origin) === true &&
419
+ credential.kind === "oauth" &&
420
+ credential.credential.type === "oauth"
421
+ );
422
+ }
423
+
424
+ export function filterAutoImportOAuthCredentials(credentials: readonly ImportableCredential[]): ImportableCredential[] {
425
+ return credentials.filter(isAutoImportOAuthCredential);
426
+ }
427
+
411
428
  /**
412
429
  * Persist discovered credentials via `upsert`. Each credential is imported
413
430
  * independently; a failure on one is recorded without aborting the rest.
@@ -15,6 +15,16 @@ These instructions teach a Hermes-style coordinator how to operate GJC through t
15
15
  6. Use `{{TOOL_PREFIX}}_report_status` for coordinator-visible status and final reports.
16
16
  7. Use `{{TOOL_PREFIX}}_read_tail` only as advisory debug output when structured turn state is insufficient.
17
17
 
18
+ ## Prefer high-level delegation
19
+
20
+ When the goal is to hand GJC a whole workflow rather than micro-manage one prompt, prefer the first-class delegate tools over manual `{{TOOL_PREFIX}}_start_session` + `{{TOOL_PREFIX}}_send_prompt` sequencing:
21
+
22
+ - `gjc_delegate_plan` — run consensus planning (`/skill:ralplan`) to a pending-approval plan.
23
+ - `gjc_delegate_execute` — run execution (`/skill:ultragoal`) to completion with verification.
24
+ - `gjc_delegate_team` — run parallel team execution (`/skill:team`) with internal tmux workers.
25
+
26
+ Each delegate starts (or reuses) a session, sends one workflow-tagged turn, and returns a durable `turn_id`. Pass `cwd` and `task`; set `allow_mutation: true` only when the bridge startup mutation class is enabled and the user has approved changes. Poll the returned `turn_id` with `{{TOOL_PREFIX}}_await_turn` or watch for the `delegation.started` event, exactly as with `send_prompt`. Drop to the manual start/send tools only for fine-grained control the delegate tools do not cover.
27
+
18
28
  ## Event watch
19
29
 
20
30
  `{{TOOL_PREFIX}}_watch_events` is a bounded long-poll read tool. Call it with `after_seq` set to the last stored sequence number, optional `session_id` or `event_types`, `timeout_ms` up to 30000, and `limit` up to 100. Store the returned `latest_seq` before the next wait. A timeout with no events is not failure; call again or use the turn/status read tools for a snapshot.