@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/main.ts CHANGED
@@ -47,6 +47,7 @@ import {
47
47
  import type { AgentSession } from "./session/agent-session";
48
48
  import type { AuthStorage } from "./session/auth-storage";
49
49
  import { resolveResumableSession, type SessionInfo, SessionManager } from "./session/session-manager";
50
+ import { runStartupCredentialAutoImportIfNeeded } from "./setup/credential-auto-import";
50
51
  import { formatModelOnboardingGuidance } from "./setup/model-onboarding-guidance";
51
52
  import { executeBuiltinSlashCommand } from "./slash-commands/builtin-registry";
52
53
  import { resolvePromptInput } from "./system-prompt";
@@ -856,6 +857,14 @@ export async function runRootCommand(
856
857
  settingsInstance.get("theme.light"),
857
858
  );
858
859
 
860
+ const credentialAutoImportNotice = isInteractive
861
+ ? await logger.time("credentialAutoImport", runStartupCredentialAutoImportIfNeeded, {
862
+ authStorage,
863
+ modelRegistry,
864
+ agentDir: settingsInstance.getAgentDir(),
865
+ })
866
+ : undefined;
867
+
859
868
  let scopedModels: ScopedModel[] = [];
860
869
  const modelPatterns = parsedArgs.models ?? settingsInstance.get("enabledModels");
861
870
  const modelMatchPreferences = {
@@ -895,6 +904,24 @@ export async function runRootCommand(
895
904
  sessionManager = await SessionManager.open(selectedPath);
896
905
  }
897
906
 
907
+ // Restore the resumed session's working directory so the HUD branch, the
908
+ // project path, and the agent's tools all match where the session was
909
+ // created. A `--worktree` session lives in a linked worktree whose path
910
+ // differs from where `--continue`/`--resume` is invoked, which would
911
+ // otherwise leave the HUD pinned to the main checkout's branch.
912
+ if (sessionManager && !parsedArgs.cwd) {
913
+ const sessionCwd = sessionManager.getCwd();
914
+ if (sessionCwd && normalizePathForComparison(sessionCwd) !== normalizePathForComparison(getProjectDir())) {
915
+ try {
916
+ if ((await fs.stat(sessionCwd)).isDirectory()) {
917
+ setProjectDir(sessionCwd);
918
+ }
919
+ } catch {
920
+ // Session cwd no longer exists (e.g. worktree removed); keep current dir.
921
+ }
922
+ }
923
+ }
924
+
898
925
  const { options: sessionOptions } = await logger.time(
899
926
  "buildSessionOptions",
900
927
  buildSessionOptions,
@@ -976,6 +1003,9 @@ export async function runRootCommand(
976
1003
  if (modelRegistryError) {
977
1004
  notifs.push({ kind: "error", message: modelRegistryError.message });
978
1005
  }
1006
+ if (credentialAutoImportNotice) {
1007
+ notifs.push({ kind: "info", message: credentialAutoImportNotice });
1008
+ }
979
1009
 
980
1010
  if (isInteractive && !session.model && !modelFallbackMessage) {
981
1011
  notifs.push({
@@ -244,6 +244,7 @@ export function mapAgentSessionEventToAcpSessionUpdates(
244
244
  case "retry_fallback_succeeded":
245
245
  case "ttsr_triggered":
246
246
  case "irc_message":
247
+ case "subagent_steer_message":
247
248
  case "notice":
248
249
  case "thinking_level_changed":
249
250
  case "goal_updated":
@@ -17,6 +17,10 @@ export interface HookEditorOptions {
17
17
  promptStyle?: boolean;
18
18
  }
19
19
 
20
+ function isWindowsRawLfNewlineInput(keyData: string): boolean {
21
+ return process.platform === "win32" && keyData === "\n";
22
+ }
23
+
20
24
  export class HookEditorComponent extends Container {
21
25
  #editor: Editor;
22
26
  #onSubmitCallback: (value: string) => void;
@@ -92,8 +96,9 @@ export class HookEditorComponent extends Container {
92
96
  return;
93
97
  }
94
98
 
95
- // Submit on any plain Enter encoding, including terminals that report unmodified Enter as LF.
96
- if (matchesKey(keyData, "enter") || matchesKey(keyData, "return")) {
99
+ // Submit on plain Enter encodings. On Windows, raw LF is reserved for terminal
100
+ // newline mappings (Shift+Enter/Ctrl+J/Ctrl+Enter); plain Enter reports CR.
101
+ if (!isWindowsRawLfNewlineInput(keyData) && (matchesKey(keyData, "enter") || matchesKey(keyData, "return"))) {
97
102
  this.#onSubmitCallback(this.#editor.getText());
98
103
  return;
99
104
  }
@@ -4,6 +4,7 @@ import { Container, matchesKey, Spacer, TruncatedText } from "@gajae-code/tui";
4
4
  import { theme } from "../../modes/theme/theme";
5
5
  import { matchesSelectCancel } from "../../modes/utils/keybinding-matchers";
6
6
  import type { AuthStorage } from "../../session/auth-storage";
7
+ import type { ImportableCredential } from "../../setup/credential-import";
7
8
  import { DynamicBorder } from "./dynamic-border";
8
9
 
9
10
  const OAUTH_SELECTOR_MAX_VISIBLE = 10;
@@ -22,6 +23,7 @@ export class OAuthSelectorComponent extends Container {
22
23
  #validateAuthCallback?: (providerId: string) => Promise<boolean>;
23
24
  #requestRenderCallback?: () => void;
24
25
  #authState: Map<string, "checking" | "valid" | "invalid"> = new Map();
26
+ #externalCredentialCandidates: ImportableCredential[] = [];
25
27
  #spinnerFrame: number = 0;
26
28
  #spinnerInterval?: NodeJS.Timeout;
27
29
  #validationGeneration: number = 0;
@@ -33,6 +35,7 @@ export class OAuthSelectorComponent extends Container {
33
35
  options?: {
34
36
  validateAuth?: (providerId: string) => Promise<boolean>;
35
37
  requestRender?: () => void;
38
+ externalCredentialCandidates?: ImportableCredential[];
36
39
  },
37
40
  ) {
38
41
  super();
@@ -42,6 +45,7 @@ export class OAuthSelectorComponent extends Container {
42
45
  this.#onCancelCallback = onCancel;
43
46
  this.#validateAuthCallback = options?.validateAuth;
44
47
  this.#requestRenderCallback = options?.requestRender;
48
+ this.#externalCredentialCandidates = options?.externalCredentialCandidates ?? [];
45
49
  // Load all OAuth providers
46
50
  this.#loadProviders();
47
51
  this.addChild(new DynamicBorder());
@@ -195,6 +199,21 @@ export class OAuthSelectorComponent extends Container {
195
199
  this.#listContainer.addChild(new Spacer(1));
196
200
  this.#listContainer.addChild(new TruncatedText(theme.fg("warning", ` ${this.#statusMessage}`), 0, 0));
197
201
  }
202
+ if (this.#mode === "login" && this.#externalCredentialCandidates.length > 0) {
203
+ this.#listContainer.addChild(new Spacer(1));
204
+ for (const credential of this.#externalCredentialCandidates) {
205
+ this.#listContainer.addChild(
206
+ new TruncatedText(
207
+ theme.fg(
208
+ "success",
209
+ ` ${theme.status.success} Imported ${credential.provider} from ${credential.source}`,
210
+ ),
211
+ 0,
212
+ 0,
213
+ ),
214
+ );
215
+ }
216
+ }
198
217
  }
199
218
  handleInput(keyData: string): void {
200
219
  // Up arrow
@@ -83,6 +83,7 @@ export class EventController {
83
83
  todo_reminder: e => this.#handleTodoReminder(e),
84
84
  todo_auto_clear: e => this.#handleTodoAutoClear(e),
85
85
  irc_message: e => this.#handleIrcMessage(e),
86
+ subagent_steer_message: e => this.#handleSubagentSteerMessage(e),
86
87
  notice: e => this.#handleNotice(e),
87
88
  thinking_level_changed: async () => {},
88
89
  goal_updated: async () => {},
@@ -284,6 +285,25 @@ export class EventController {
284
285
  this.ctx.ui.requestRender();
285
286
  }
286
287
 
288
+ async #handleSubagentSteerMessage(
289
+ event: Extract<AgentSessionEvent, { type: "subagent_steer_message" }>,
290
+ ): Promise<void> {
291
+ const details = event.message.details as
292
+ | { observationId?: string; from?: string; to?: string; body?: string; state?: string }
293
+ | undefined;
294
+ const obsId = details?.observationId;
295
+ const signature = obsId
296
+ ? `steer:${obsId}`
297
+ : `${event.message.role}:${event.message.customType}:${event.message.timestamp}:${details?.from}:${details?.to}:${details?.state}:${details?.body}`;
298
+ if (this.#renderedCustomMessages.has(signature)) {
299
+ return;
300
+ }
301
+ this.#renderedCustomMessages.add(signature);
302
+ this.#resetReadGroup();
303
+ this.ctx.addMessageToChat(event.message);
304
+ this.ctx.ui.requestRender();
305
+ }
306
+
287
307
  #scheduleIrcExpiry(signature: string, components: Component[]): void {
288
308
  if (components.length === 0 || this.#ircExpiryTimers.has(signature)) return;
289
309
  const timer = setTimeout(() => {
@@ -29,10 +29,14 @@ import {
29
29
  setTheme,
30
30
  theme,
31
31
  } from "../../modes/theme/theme";
32
- import type { InteractiveModeContext } from "../../modes/types";
32
+ import type { InteractiveModeContext, OAuthSelectorOptions } from "../../modes/types";
33
33
  import { type SessionInfo, SessionManager } from "../../session/session-manager";
34
34
  import { FileSessionStorage } from "../../session/session-storage";
35
- import { discoverExternalCredentials, formatDiscoverySummary, importCredentials } from "../../setup/credential-import";
35
+ import {
36
+ CREDENTIAL_AUTO_IMPORT_ROTATION_WARNING,
37
+ runExternalCredentialAutoImport,
38
+ } from "../../setup/credential-auto-import";
39
+ import { filterAutoImportOAuthCredentials, formatDiscoverySummary } from "../../setup/credential-import";
36
40
  import {
37
41
  MODEL_ONBOARDING_API_PROVIDER_COMMAND,
38
42
  MODEL_ONBOARDING_PROVIDER_PRESET_COMMAND,
@@ -156,10 +160,22 @@ export class SelectorController {
156
160
 
157
161
  async #handleCredentialImport(): Promise<void> {
158
162
  this.ctx.showStatus("Scanning for existing Claude Code / Codex CLI credentials…");
159
- const result = await discoverExternalCredentials();
160
- const summaryLines = formatDiscoverySummary(result);
163
+ const preview = await runExternalCredentialAutoImport({
164
+ authStorage: {
165
+ importCredentialIfAbsent: async () => ({
166
+ inserted: false,
167
+ reason: "skipped-existing",
168
+ provider: "",
169
+ entries: [],
170
+ }),
171
+ },
172
+ trigger: "bare-login",
173
+ });
174
+ const result = preview.discovery ?? { importable: [], skipped: [], environment: [] };
175
+ const candidates = filterAutoImportOAuthCredentials(result.importable);
176
+ const summaryLines = formatDiscoverySummary({ ...result, importable: candidates });
161
177
 
162
- if (result.importable.length === 0) {
178
+ if (candidates.length === 0) {
163
179
  this.ctx.chatContainer.addChild(new Spacer(1));
164
180
  for (const line of summaryLines) {
165
181
  this.ctx.chatContainer.addChild(new Text(theme.fg("dim", line), 1, 0));
@@ -168,7 +184,7 @@ export class SelectorController {
168
184
  new Text(
169
185
  theme.fg(
170
186
  "warning",
171
- "No importable Claude/Codex credentials found. Use /login or add a custom provider.",
187
+ "No importable Claude/Codex OAuth credentials found. Use /login or add a custom provider.",
172
188
  ),
173
189
  1,
174
190
  0,
@@ -179,7 +195,7 @@ export class SelectorController {
179
195
  }
180
196
 
181
197
  const confirmed = await this.ctx.showHookConfirm(
182
- `Import ${result.importable.length} credential(s)?`,
198
+ `Import ${candidates.length} credential(s)?`,
183
199
  summaryLines.join("\n"),
184
200
  );
185
201
  if (!confirmed) {
@@ -187,9 +203,10 @@ export class SelectorController {
187
203
  return;
188
204
  }
189
205
 
190
- const summary = await importCredentials(result.importable, (provider, credential) =>
191
- this.ctx.session.modelRegistry.authStorage.upsertCredential(provider, credential),
192
- );
206
+ const summary = await runExternalCredentialAutoImport({
207
+ authStorage: this.ctx.session.modelRegistry.authStorage,
208
+ trigger: "bare-login",
209
+ });
193
210
  await this.ctx.session.modelRegistry.refresh();
194
211
 
195
212
  this.ctx.chatContainer.addChild(new Spacer(1));
@@ -202,13 +219,15 @@ export class SelectorController {
202
219
  ),
203
220
  );
204
221
  }
205
- for (const failure of summary.failed) {
222
+ for (const skip of summary.skipped) {
206
223
  this.ctx.chatContainer.addChild(
207
- new Text(
208
- theme.fg("error", `${theme.status.error} Failed ${failure.credential.provider}: ${failure.error}`),
209
- 1,
210
- 0,
211
- ),
224
+ new Text(theme.fg("dim", `${theme.status.info} Skipped ${skip.credential.provider}: ${skip.reason}`), 1, 0),
225
+ );
226
+ }
227
+ for (const failure of summary.failures) {
228
+ const provider = failure.credential?.provider ?? failure.origin ?? "credential discovery";
229
+ this.ctx.chatContainer.addChild(
230
+ new Text(theme.fg("error", `${theme.status.error} Failed ${provider}: ${failure.failureClass}`), 1, 0),
212
231
  );
213
232
  }
214
233
  if (summary.imported.length > 0) {
@@ -1232,7 +1251,11 @@ export class SelectorController {
1232
1251
  }
1233
1252
  }
1234
1253
 
1235
- async showOAuthSelector(mode: "login" | "logout", providerId?: string): Promise<void> {
1254
+ async showOAuthSelector(
1255
+ mode: "login" | "logout",
1256
+ providerId?: string,
1257
+ options?: OAuthSelectorOptions,
1258
+ ): Promise<void> {
1236
1259
  if (providerId) {
1237
1260
  const oauthProvider = getOAuthProviders().find(provider => provider.id === providerId);
1238
1261
  if (!oauthProvider && !this.ctx.session.modelRegistry.getModelProfiles().has(providerId)) {
@@ -1259,6 +1282,45 @@ export class SelectorController {
1259
1282
  }
1260
1283
  }
1261
1284
 
1285
+ let externalCredentialCandidates: ReturnType<typeof filterAutoImportOAuthCredentials> = [];
1286
+ if (
1287
+ mode === "login" &&
1288
+ providerId === undefined &&
1289
+ options?.allowExternalCredentialDiscovery === true &&
1290
+ options.trigger === "bare-login"
1291
+ ) {
1292
+ const preview = await runExternalCredentialAutoImport({
1293
+ authStorage: {
1294
+ importCredentialIfAbsent: async () => ({
1295
+ inserted: false,
1296
+ reason: "skipped-existing",
1297
+ provider: "",
1298
+ entries: [],
1299
+ }),
1300
+ },
1301
+ trigger: "bare-login",
1302
+ discover: options.externalCredentialDiscover,
1303
+ });
1304
+ const result = preview.discovery ?? { importable: [], skipped: [], environment: [] };
1305
+ const candidates = filterAutoImportOAuthCredentials(result.importable);
1306
+ if (candidates.length > 0) {
1307
+ const confirmed = await this.ctx.showHookConfirm(
1308
+ `Import ${candidates.length} external credential(s)?`,
1309
+ `${formatDiscoverySummary({ ...result, importable: candidates }).join("\n")}\n\n${CREDENTIAL_AUTO_IMPORT_ROTATION_WARNING}`,
1310
+ );
1311
+ if (confirmed) {
1312
+ const summary = await runExternalCredentialAutoImport({
1313
+ authStorage: this.ctx.session.modelRegistry.authStorage,
1314
+ trigger: "bare-login",
1315
+ discover: options.externalCredentialDiscover,
1316
+ });
1317
+ externalCredentialCandidates = summary.imported;
1318
+ if (externalCredentialCandidates.length > 0) {
1319
+ await this.ctx.session.modelRegistry.refresh("offline");
1320
+ }
1321
+ }
1322
+ }
1323
+ }
1262
1324
  this.showSelector(done => {
1263
1325
  let selector: OAuthSelectorComponent;
1264
1326
  selector = new OAuthSelectorComponent(
@@ -1289,6 +1351,7 @@ export class SelectorController {
1289
1351
  requestRender: () => {
1290
1352
  this.ctx.ui.requestRender();
1291
1353
  },
1354
+ externalCredentialCandidates,
1292
1355
  },
1293
1356
  );
1294
1357
  return { component: selector, focus: selector };
@@ -2502,8 +2502,12 @@ export class InteractiveMode implements InteractiveModeContext {
2502
2502
  return this.#selectorController.handleSessionDeleteCommand();
2503
2503
  }
2504
2504
 
2505
- showOAuthSelector(mode: "login" | "logout", providerId?: string): Promise<void> {
2506
- return this.#selectorController.showOAuthSelector(mode, providerId);
2505
+ showOAuthSelector(
2506
+ mode: "login" | "logout",
2507
+ providerId?: string,
2508
+ options?: import("./types").OAuthSelectorOptions,
2509
+ ): Promise<void> {
2510
+ return this.#selectorController.showOAuthSelector(mode, providerId, options);
2507
2511
  }
2508
2512
 
2509
2513
  showHookConfirm(title: string, message: string): Promise<boolean> {
@@ -78,6 +78,7 @@ export async function initializeExtensions(session: AgentSession, options: Initi
78
78
  shutdown,
79
79
  getContextUsage: () => session.getContextUsage(),
80
80
  getSystemPrompt: () => session.systemPrompt,
81
+ getWorkflowGate: () => session.getWorkflowGateEmitter(),
81
82
  compact: instructionsOrOptions => runExtensionCompact(session, instructionsOrOptions),
82
83
  },
83
84
  // ExtensionCommandContextActions — commands invokable via prompt("/command")
@@ -49,6 +49,7 @@ const AGENT_SESSION_EVENT_TYPE_REGISTRY: Record<AgentWireEventType, true> = {
49
49
  todo_reminder: true,
50
50
  todo_auto_clear: true,
51
51
  irc_message: true,
52
+ subagent_steer_message: true,
52
53
  notice: true,
53
54
  thinking_level_changed: true,
54
55
  goal_updated: true,
@@ -50,6 +50,7 @@ export function agentSessionEventType(event: AgentSessionEvent): AgentWireEventT
50
50
  case "todo_reminder":
51
51
  case "todo_auto_clear":
52
52
  case "irc_message":
53
+ case "subagent_steer_message":
53
54
  case "notice":
54
55
  case "thinking_level_changed":
55
56
  case "goal_updated":
@@ -235,6 +235,22 @@ export function observeAgentSessionEvent(event: AgentSessionEvent): AgentWireOwn
235
235
  semantic: false,
236
236
  coalesceKey: null,
237
237
  });
238
+ case "subagent_steer_message": {
239
+ const details = recordObject(event.message.details);
240
+ return obs(event, {
241
+ kind: "rpc_subagent_steer",
242
+ signal: null,
243
+ evidence: {
244
+ from: str(details?.from) ?? null,
245
+ to: str(details?.to) ?? null,
246
+ state: str(details?.state) ?? null,
247
+ observationId: str(details?.observationId) ?? null,
248
+ },
249
+ severity: "info",
250
+ semantic: false,
251
+ coalesceKey: null,
252
+ });
253
+ }
238
254
  case "notice": {
239
255
  const level = event.level;
240
256
  return obs(event, {
@@ -59,6 +59,14 @@ export interface WorkflowGateEmitter {
59
59
  isUnattended(): boolean;
60
60
  /** Open + emit a gate; resolves with the agent's answer (from workflow_gate_response). */
61
61
  emitGate(input: OpenGateInput): Promise<unknown>;
62
+ /**
63
+ * Optional bridge surface (present on {@link UnattendedSessionControlPlane}) that
64
+ * lets an in-process extension observe emitted gates and answer them — used by
65
+ * the notifications SDK to resolve a real ask gate from a remote reply.
66
+ */
67
+ onGateEmitted?(listener: (gate: RpcWorkflowGate) => void): () => void;
68
+ resolveGate?(response: RpcWorkflowGateResponse): Promise<RpcWorkflowGateResolution>;
69
+ listPendingGates?(): RpcWorkflowGate[];
62
70
  }
63
71
 
64
72
  export interface UnattendedSessionOptions {
@@ -82,6 +90,7 @@ export class UnattendedSessionControlPlane implements RpcUnattendedControlPlane,
82
90
  #broker: WorkflowGateBroker | undefined;
83
91
  readonly #pending = new Map<string, { resolve: (answer: unknown) => void; reject: (err: Error) => void }>();
84
92
  readonly #earlyAnswers = new Map<string, unknown>();
93
+ readonly #gateListeners = new Set<(gate: RpcWorkflowGate) => void>();
85
94
 
86
95
  constructor(private readonly opts: UnattendedSessionOptions) {}
87
96
 
@@ -89,6 +98,12 @@ export class UnattendedSessionControlPlane implements RpcUnattendedControlPlane,
89
98
  return this.#controller !== undefined;
90
99
  }
91
100
 
101
+ /** Observe every emitted gate (e.g. so an extension can map an ask to its gate_id). */
102
+ onGateEmitted(listener: (gate: RpcWorkflowGate) => void): () => void {
103
+ this.#gateListeners.add(listener);
104
+ return () => this.#gateListeners.delete(listener);
105
+ }
106
+
92
107
  get controller(): UnattendedRunController | undefined {
93
108
  return this.#controller;
94
109
  }
@@ -195,6 +210,13 @@ export class UnattendedSessionControlPlane implements RpcUnattendedControlPlane,
195
210
  return Promise.reject(new Error("cannot emit a workflow gate before unattended mode is negotiated"));
196
211
  }
197
212
  const gate = this.#broker.openGate(input);
213
+ for (const listener of this.#gateListeners) {
214
+ try {
215
+ listener(gate);
216
+ } catch {
217
+ // A misbehaving observer must never break gate emission.
218
+ }
219
+ }
198
220
  if (this.#earlyAnswers.has(gate.gate_id)) {
199
221
  const answer = this.#earlyAnswers.get(gate.gate_id);
200
222
  this.#earlyAnswers.delete(gate.gate_id);
@@ -17,6 +17,7 @@ import type { MCPManager } from "../runtime-mcp";
17
17
  import type { AgentSession, AgentSessionEvent } from "../session/agent-session";
18
18
  import type { HistoryStorage } from "../session/history-storage";
19
19
  import type { SessionContext, SessionManager } from "../session/session-manager";
20
+ import type { CredentialAutoImportOptions } from "../setup/credential-auto-import";
20
21
  import type { LspStartupServerInfo } from "../tools";
21
22
  import type { AssistantMessageComponent } from "./components/assistant-message";
22
23
  import type { BashExecutionComponent } from "./components/bash-execution";
@@ -247,7 +248,7 @@ export interface InteractiveModeContext {
247
248
  showSessionSelector(): void;
248
249
  handleResumeSession(sessionPath: string): Promise<void>;
249
250
  handleSessionDeleteCommand(): Promise<void>;
250
- showOAuthSelector(mode: "login" | "logout", providerId?: string): Promise<void>;
251
+ showOAuthSelector(mode: "login" | "logout", providerId?: string, options?: OAuthSelectorOptions): Promise<void>;
251
252
  showHookConfirm(title: string, message: string): Promise<boolean>;
252
253
  showDebugSelector(): void;
253
254
  showSessionObserver(): void;
@@ -311,3 +312,8 @@ export interface InteractiveModeContext {
311
312
  showExtensionError(extensionPath: string, error: string): void;
312
313
  showToolError(toolName: string, error: string): void;
313
314
  }
315
+ export interface OAuthSelectorOptions {
316
+ allowExternalCredentialDiscovery?: boolean;
317
+ trigger?: "bare-login";
318
+ externalCredentialDiscover?: CredentialAutoImportOptions["discover"];
319
+ }
@@ -203,6 +203,29 @@ export class UiHelpers {
203
203
  }
204
204
  return components;
205
205
  }
206
+ if (message.customType === "subagent:steer" || message.customType === "subagent:steer:relay") {
207
+ const details = (
208
+ message as CustomMessage<{
209
+ from?: string;
210
+ to?: string;
211
+ body?: string;
212
+ state?: string;
213
+ }>
214
+ ).details;
215
+ const components: Component[] = [];
216
+ const header = `${theme.fg("accent", `[Steer ${details?.state ?? "queued"}] ${details?.from ?? "?"} ⇨ ${details?.to ?? "?"}`)}`;
217
+ const headerComponent = new Text(header, 1, 0);
218
+ this.ctx.chatContainer.addChild(headerComponent);
219
+ components.push(headerComponent);
220
+ if (details?.body) {
221
+ for (const line of details.body.split("\n")) {
222
+ const lineComponent = new Text(theme.fg("muted", ` ${line}`), 0, 0);
223
+ this.ctx.chatContainer.addChild(lineComponent);
224
+ components.push(lineComponent);
225
+ }
226
+ }
227
+ return components;
228
+ }
206
229
  const renderer = this.ctx.session.extensionRunner?.getMessageRenderer(message.customType);
207
230
  // Both HookMessage and CustomMessage have the same structure, cast for compatibility
208
231
  const component = new CustomMessageComponent(message as CustomMessage<unknown>, renderer);
@@ -0,0 +1,50 @@
1
+ /**
2
+ * In-thread configuration slash commands for the threaded session surface.
3
+ *
4
+ * Replies are thread-native now (the old `/answer <sessionId> …` command is
5
+ * removed), but the user can still adjust per-surface behaviour from inside a
6
+ * session thread with small slash commands:
7
+ *
8
+ * - `/verbose` switch the mirror to verbose (full tool output + reasoning)
9
+ * - `/lean` switch back to lean (assistant text + tool names)
10
+ * - `/verbosity lean|verbose`
11
+ * - `/redact on|off` toggle redaction of streamed content
12
+ *
13
+ * This parser is pure so the command grammar is unit-testable; the daemon maps
14
+ * the returned change onto a `config_command` frame / settings update.
15
+ */
16
+
17
+ /** A parsed in-thread configuration change. */
18
+ export interface ConfigCommandChange {
19
+ verbosity?: "lean" | "verbose";
20
+ redact?: boolean;
21
+ }
22
+
23
+ /**
24
+ * Parse an in-thread config command. Returns the requested change, or
25
+ * `undefined` when the text is not a recognised config command (so the daemon
26
+ * can fall through to treating it as a free-text injection).
27
+ */
28
+ export function parseInThreadConfigCommand(text: string): ConfigCommandChange | undefined {
29
+ const trimmed = text.trim();
30
+ if (!trimmed.startsWith("/")) return undefined;
31
+ const [rawCommand, ...rest] = trimmed.slice(1).split(/\s+/);
32
+ const command = rawCommand?.toLowerCase();
33
+ const arg = rest[0]?.toLowerCase();
34
+
35
+ switch (command) {
36
+ case "verbose":
37
+ return { verbosity: "verbose" };
38
+ case "lean":
39
+ return { verbosity: "lean" };
40
+ case "verbosity":
41
+ if (arg === "lean" || arg === "verbose") return { verbosity: arg };
42
+ return undefined;
43
+ case "redact":
44
+ if (arg === "on" || arg === "true" || arg === "1") return { redact: true };
45
+ if (arg === "off" || arg === "false" || arg === "0") return { redact: false };
46
+ return undefined;
47
+ default:
48
+ return undefined;
49
+ }
50
+ }
@@ -0,0 +1,107 @@
1
+ import * as crypto from "node:crypto";
2
+ import type { Settings } from "../config/settings";
3
+
4
+ export interface NotificationConfig {
5
+ enabled: boolean;
6
+ botToken?: string;
7
+ chatId?: string;
8
+ redact: boolean;
9
+ verbosity: "lean" | "verbose";
10
+ idleTimeoutMs: number;
11
+ }
12
+
13
+ /** Read typed config from Settings. */
14
+ export function getNotificationConfig(settings: Settings): NotificationConfig {
15
+ return {
16
+ enabled: settings.get("notifications.enabled"),
17
+ botToken: settings.get("notifications.telegram.botToken"),
18
+ chatId: settings.get("notifications.telegram.chatId"),
19
+ redact: settings.get("notifications.redact"),
20
+ verbosity: settings.get("notifications.verbosity") === "verbose" ? "verbose" : "lean",
21
+ idleTimeoutMs: settings.get("notifications.daemon.idleTimeoutMs"),
22
+ };
23
+ }
24
+
25
+ /** Is global config sufficient for auto-on (enabled + botToken + chatId all present)? */
26
+ export function isGloballyConfigured(cfg: NotificationConfig): boolean {
27
+ return cfg.enabled && Boolean(cfg.botToken) && Boolean(cfg.chatId);
28
+ }
29
+
30
+ /** Resolve whether the notifications extension should be registered at SDK startup. */
31
+ export function shouldRegisterNotificationsExtension(input: {
32
+ env: NodeJS.ProcessEnv;
33
+ cfg?: NotificationConfig;
34
+ }): boolean {
35
+ if (input.env.GJC_NOTIFICATIONS === "0") return false;
36
+ if (input.env.GJC_NOTIFICATIONS === "1" || input.env.GJC_NOTIFICATIONS_TOKEN) return true;
37
+ return input.cfg ? isGloballyConfigured(input.cfg) : false;
38
+ }
39
+
40
+ /**
41
+ * Resolve whether THIS session should run notifications.
42
+ * Precedence (highest first):
43
+ * 1) env.GJC_NOTIFICATIONS === "0" -> false (hard opt-out)
44
+ * 2) sessionDisabled === true -> false (local /notify off)
45
+ * 3) env.GJC_NOTIFICATIONS === "1" || env.GJC_NOTIFICATIONS_TOKEN present -> true (legacy explicit)
46
+ * 4) isGloballyConfigured(cfg) -> true (global auto-on)
47
+ * 5) otherwise false
48
+ */
49
+ export function isSessionNotificationsEnabled(input: {
50
+ cfg: NotificationConfig;
51
+ env: NodeJS.ProcessEnv;
52
+ sessionDisabled: boolean;
53
+ }): boolean {
54
+ if (input.env.GJC_NOTIFICATIONS === "0") return false;
55
+ if (input.sessionDisabled) return false;
56
+ if (input.env.GJC_NOTIFICATIONS === "1" || input.env.GJC_NOTIFICATIONS_TOKEN) return true;
57
+ return isGloballyConfigured(input.cfg);
58
+ }
59
+
60
+ /** Mask a bot token for display: first 4 chars + "…" + "(len N)"; "(unset)" when undefined/empty. Never reveal full token. */
61
+ export function maskToken(token: string | undefined): string {
62
+ if (!token) return "(unset)";
63
+ return `${token.slice(0, 4)}…(len ${token.length})`;
64
+ }
65
+
66
+ /** Stable non-reversible fingerprint of a token: sha256 hex, first 12 chars. */
67
+ export function tokenFingerprint(token: string): string {
68
+ return crypto.createHash("sha256").update(token).digest("hex").slice(0, 12);
69
+ }
70
+
71
+ /** Short session tag for display, e.g. last 6 chars of sessionId. */
72
+ export function sessionTag(sessionId: string): string {
73
+ return sessionId.slice(-6);
74
+ }
75
+
76
+ export interface RedactableAction {
77
+ id: string;
78
+ kind: string;
79
+ sessionId: string;
80
+ question?: string;
81
+ options?: string[];
82
+ summary?: string;
83
+ }
84
+
85
+ /**
86
+ * When redact is true, strip sensitive content for remote delivery:
87
+ * - ask: NOT redacted. An ask is an interactive prompt the human must read and
88
+ * answer on the remote surface; redacting its question/options would make it
89
+ * unanswerable, defeating remote answering. Asks are returned unchanged.
90
+ * - idle: summary removed, (no question/options).
91
+ * When redact is false, return the action unchanged.
92
+ *
93
+ * Redaction still applies to streamed content frames (turn_stream, context_update,
94
+ * image_attachment) which are suppressed at their emit sites, not here.
95
+ */
96
+ export function buildRedactedAction(
97
+ action: RedactableAction,
98
+ opts: { redact: boolean; sessionTag: string },
99
+ ): RedactableAction {
100
+ if (!opts.redact) return action;
101
+
102
+ // Asks stay fully readable/answerable even under redaction.
103
+ if (action.kind === "ask") return action;
104
+
105
+ const { summary: _summary, question: _question, ...base } = action;
106
+ return base;
107
+ }