@sayknow-cli/coding-agent 0.2.6 → 0.3.0

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 (145) hide show
  1. package/CHANGELOG.md +32 -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 +71 -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/defaults/skc-defaults.d.ts +13 -2
  18. package/dist/types/extensibility/extensions/types.d.ts +8 -0
  19. package/dist/types/extensibility/plugins/index.d.ts +1 -0
  20. package/dist/types/extensibility/plugins/security-scanner.d.ts +31 -0
  21. package/dist/types/modes/components/oauth-selector.d.ts +2 -0
  22. package/dist/types/modes/controllers/selector-controller.d.ts +2 -2
  23. package/dist/types/modes/interactive-mode.d.ts +1 -1
  24. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +10 -0
  25. package/dist/types/modes/types.d.ts +7 -1
  26. package/dist/types/notifications/config-commands.d.ts +26 -0
  27. package/dist/types/notifications/config.d.ts +61 -0
  28. package/dist/types/notifications/helpers.d.ts +55 -0
  29. package/dist/types/notifications/html-format.d.ts +62 -0
  30. package/dist/types/notifications/index.d.ts +28 -0
  31. package/dist/types/notifications/rate-limit-pool.d.ts +93 -0
  32. package/dist/types/notifications/telegram-cli.d.ts +19 -0
  33. package/dist/types/notifications/telegram-daemon-cli.d.ts +11 -0
  34. package/dist/types/notifications/telegram-daemon-control.d.ts +56 -0
  35. package/dist/types/notifications/telegram-daemon.d.ts +276 -0
  36. package/dist/types/notifications/telegram-reference.d.ts +111 -0
  37. package/dist/types/notifications/threaded-inbound.d.ts +58 -0
  38. package/dist/types/notifications/threaded-render.d.ts +66 -0
  39. package/dist/types/notifications/topic-registry.d.ts +67 -0
  40. package/dist/types/rlm/index.d.ts +12 -0
  41. package/dist/types/session/agent-session.d.ts +39 -2
  42. package/dist/types/session/auth-storage.d.ts +1 -1
  43. package/dist/types/setup/credential-auto-import.d.ts +63 -0
  44. package/dist/types/setup/credential-import.d.ts +3 -0
  45. package/dist/types/setup/host-plugin-setup.d.ts +39 -0
  46. package/dist/types/skc-runtime/launch-tmux.d.ts +1 -0
  47. package/dist/types/skc-runtime/state-writer.d.ts +2 -0
  48. package/dist/types/skc-runtime/tmux-common.d.ts +3 -0
  49. package/dist/types/skc-runtime/tmux-sessions.d.ts +2 -0
  50. package/dist/types/skc-runtime/ultragoal-guard.d.ts +15 -0
  51. package/dist/types/skc-runtime/ultragoal-runtime.d.ts +14 -0
  52. package/dist/types/tools/ask-answer-registry.d.ts +13 -0
  53. package/dist/types/tools/index.d.ts +18 -0
  54. package/dist/types/tools/subagent.d.ts +3 -0
  55. package/package.json +7 -7
  56. package/scripts/build-binary.ts +3 -0
  57. package/src/async/job-manager.ts +5 -1
  58. package/src/cli/daemon-cli.ts +122 -0
  59. package/src/cli/notify-cli.ts +274 -0
  60. package/src/cli/plugin-cli.ts +63 -6
  61. package/src/cli/setup-cli.ts +173 -84
  62. package/src/cli.ts +3 -3
  63. package/src/commands/daemon.ts +47 -0
  64. package/src/commands/notify.ts +61 -0
  65. package/src/commands/setup.ts +11 -1
  66. package/src/config/model-profile-activation.ts +74 -5
  67. package/src/config/model-profiles.ts +7 -4
  68. package/src/config/model-registry.ts +6 -3
  69. package/src/config/models-config-schema.ts +1 -1
  70. package/src/config/settings-schema.ts +61 -0
  71. package/src/coordinator/contract.ts +3 -0
  72. package/src/coordinator-mcp/server.ts +270 -1
  73. package/src/daemon/builtin.ts +46 -0
  74. package/src/daemon/control-types.ts +65 -0
  75. package/src/daemon/runtime.ts +51 -0
  76. package/src/defaults/skc/rules/ponytail.md +68 -0
  77. package/src/defaults/skc/skills/ultragoal/SKILL.md +16 -0
  78. package/src/defaults/skc-defaults.ts +27 -2
  79. package/src/edit/modes/replace.ts +1 -1
  80. package/src/extensibility/extensions/runner.ts +4 -0
  81. package/src/extensibility/extensions/types.ts +8 -0
  82. package/src/extensibility/plugins/index.ts +1 -0
  83. package/src/extensibility/plugins/manager.ts +50 -0
  84. package/src/extensibility/plugins/marketplace/manager.ts +42 -0
  85. package/src/extensibility/plugins/security-scanner.ts +477 -0
  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 +10 -8
  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/skc-runtime/deep-interview-recorder.ts +2 -2
  128. package/src/skc-runtime/launch-tmux.ts +10 -2
  129. package/src/skc-runtime/state-runtime.ts +18 -10
  130. package/src/skc-runtime/state-writer.ts +8 -8
  131. package/src/skc-runtime/tmux-common.ts +8 -0
  132. package/src/skc-runtime/tmux-sessions.ts +8 -1
  133. package/src/skc-runtime/ultragoal-guard.ts +57 -2
  134. package/src/skc-runtime/ultragoal-runtime.ts +105 -19
  135. package/src/skc-runtime/workflow-manifest.generated.json +27 -2
  136. package/src/skc-runtime/workflow-manifest.ts +11 -1
  137. package/src/slash-commands/builtin-registry.ts +4 -1
  138. package/src/task/executor.ts +5 -1
  139. package/src/tools/ask-answer-registry.ts +25 -0
  140. package/src/tools/ask.ts +77 -6
  141. package/src/tools/image-gen.ts +5 -8
  142. package/src/tools/index.ts +19 -0
  143. package/src/tools/inspect-image.ts +16 -11
  144. package/src/tools/subagent-render.ts +7 -0
  145. 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 "@sayknow-cli/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,
@@ -160,10 +164,22 @@ export class SelectorController {
160
164
 
161
165
  async #handleCredentialImport(): Promise<void> {
162
166
  this.ctx.showStatus("Scanning for existing Claude Code / Codex CLI credentials…");
163
- const result = await discoverExternalCredentials();
164
- const summaryLines = formatDiscoverySummary(result);
167
+ const preview = await runExternalCredentialAutoImport({
168
+ authStorage: {
169
+ importCredentialIfAbsent: async () => ({
170
+ inserted: false,
171
+ reason: "skipped-existing",
172
+ provider: "",
173
+ entries: [],
174
+ }),
175
+ },
176
+ trigger: "bare-login",
177
+ });
178
+ const result = preview.discovery ?? { importable: [], skipped: [], environment: [] };
179
+ const candidates = filterAutoImportOAuthCredentials(result.importable);
180
+ const summaryLines = formatDiscoverySummary({ ...result, importable: candidates });
165
181
 
166
- if (result.importable.length === 0) {
182
+ if (candidates.length === 0) {
167
183
  this.ctx.chatContainer.addChild(new Spacer(1));
168
184
  for (const line of summaryLines) {
169
185
  this.ctx.chatContainer.addChild(new Text(theme.fg("dim", line), 1, 0));
@@ -172,7 +188,7 @@ export class SelectorController {
172
188
  new Text(
173
189
  theme.fg(
174
190
  "warning",
175
- "No importable Claude/Codex credentials found. Use /login or add a custom provider.",
191
+ "No importable Claude/Codex OAuth credentials found. Use /login or add a custom provider.",
176
192
  ),
177
193
  1,
178
194
  0,
@@ -183,7 +199,7 @@ export class SelectorController {
183
199
  }
184
200
 
185
201
  const confirmed = await this.ctx.showHookConfirm(
186
- `Import ${result.importable.length} credential(s)?`,
202
+ `Import ${candidates.length} credential(s)?`,
187
203
  summaryLines.join("\n"),
188
204
  );
189
205
  if (!confirmed) {
@@ -191,9 +207,10 @@ export class SelectorController {
191
207
  return;
192
208
  }
193
209
 
194
- const summary = await importCredentials(result.importable, (provider, credential) =>
195
- this.ctx.session.modelRegistry.authStorage.upsertCredential(provider, credential),
196
- );
210
+ const summary = await runExternalCredentialAutoImport({
211
+ authStorage: this.ctx.session.modelRegistry.authStorage,
212
+ trigger: "bare-login",
213
+ });
197
214
  await this.ctx.session.modelRegistry.refresh();
198
215
 
199
216
  this.ctx.chatContainer.addChild(new Spacer(1));
@@ -206,13 +223,15 @@ export class SelectorController {
206
223
  ),
207
224
  );
208
225
  }
209
- for (const failure of summary.failed) {
226
+ for (const skip of summary.skipped) {
210
227
  this.ctx.chatContainer.addChild(
211
- new Text(
212
- theme.fg("error", `${theme.status.error} Failed ${failure.credential.provider}: ${failure.error}`),
213
- 1,
214
- 0,
215
- ),
228
+ new Text(theme.fg("dim", `${theme.status.info} Skipped ${skip.credential.provider}: ${skip.reason}`), 1, 0),
229
+ );
230
+ }
231
+ for (const failure of summary.failures) {
232
+ const provider = failure.credential?.provider ?? failure.origin ?? "credential discovery";
233
+ this.ctx.chatContainer.addChild(
234
+ new Text(theme.fg("error", `${theme.status.error} Failed ${provider}: ${failure.failureClass}`), 1, 0),
216
235
  );
217
236
  }
218
237
  if (summary.imported.length > 0) {
@@ -1280,7 +1299,11 @@ export class SelectorController {
1280
1299
  }
1281
1300
  }
1282
1301
 
1283
- async showOAuthSelector(mode: "login" | "logout", providerId?: string): Promise<void> {
1302
+ async showOAuthSelector(
1303
+ mode: "login" | "logout",
1304
+ providerId?: string,
1305
+ options?: OAuthSelectorOptions,
1306
+ ): Promise<void> {
1284
1307
  if (providerId) {
1285
1308
  const oauthProvider = getOAuthProviders().find(provider => provider.id === providerId);
1286
1309
  if (!oauthProvider && !this.ctx.session.modelRegistry.getModelProfiles().has(providerId)) {
@@ -1307,6 +1330,45 @@ export class SelectorController {
1307
1330
  }
1308
1331
  }
1309
1332
 
1333
+ let externalCredentialCandidates: ReturnType<typeof filterAutoImportOAuthCredentials> = [];
1334
+ if (
1335
+ mode === "login" &&
1336
+ providerId === undefined &&
1337
+ options?.allowExternalCredentialDiscovery === true &&
1338
+ options.trigger === "bare-login"
1339
+ ) {
1340
+ const preview = await runExternalCredentialAutoImport({
1341
+ authStorage: {
1342
+ importCredentialIfAbsent: async () => ({
1343
+ inserted: false,
1344
+ reason: "skipped-existing",
1345
+ provider: "",
1346
+ entries: [],
1347
+ }),
1348
+ },
1349
+ trigger: "bare-login",
1350
+ discover: options.externalCredentialDiscover,
1351
+ });
1352
+ const result = preview.discovery ?? { importable: [], skipped: [], environment: [] };
1353
+ const candidates = filterAutoImportOAuthCredentials(result.importable);
1354
+ if (candidates.length > 0) {
1355
+ const confirmed = await this.ctx.showHookConfirm(
1356
+ `Import ${candidates.length} external credential(s)?`,
1357
+ `${formatDiscoverySummary({ ...result, importable: candidates }).join("\n")}\n\n${CREDENTIAL_AUTO_IMPORT_ROTATION_WARNING}`,
1358
+ );
1359
+ if (confirmed) {
1360
+ const summary = await runExternalCredentialAutoImport({
1361
+ authStorage: this.ctx.session.modelRegistry.authStorage,
1362
+ trigger: "bare-login",
1363
+ discover: options.externalCredentialDiscover,
1364
+ });
1365
+ externalCredentialCandidates = summary.imported;
1366
+ if (externalCredentialCandidates.length > 0) {
1367
+ await this.ctx.session.modelRegistry.refresh("offline");
1368
+ }
1369
+ }
1370
+ }
1371
+ }
1310
1372
  this.showSelector(done => {
1311
1373
  let selector: OAuthSelectorComponent;
1312
1374
  selector = new OAuthSelectorComponent(
@@ -1337,6 +1399,7 @@ export class SelectorController {
1337
1399
  requestRender: () => {
1338
1400
  this.ctx.ui.requestRender();
1339
1401
  },
1402
+ externalCredentialCandidates,
1340
1403
  },
1341
1404
  );
1342
1405
  return { component: selector, focus: selector };
@@ -2503,8 +2503,12 @@ export class InteractiveMode implements InteractiveModeContext {
2503
2503
  return this.#selectorController.handleSessionDeleteCommand();
2504
2504
  }
2505
2505
 
2506
- showOAuthSelector(mode: "login" | "logout", providerId?: string): Promise<void> {
2507
- return this.#selectorController.showOAuthSelector(mode, providerId);
2506
+ showOAuthSelector(
2507
+ mode: "login" | "logout",
2508
+ providerId?: string,
2509
+ options?: import("./types").OAuthSelectorOptions,
2510
+ ): Promise<void> {
2511
+ return this.#selectorController.showOAuthSelector(mode, providerId, options);
2508
2512
  }
2509
2513
 
2510
2514
  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
+ }
@@ -205,6 +205,29 @@ export class UiHelpers {
205
205
  }
206
206
  return components;
207
207
  }
208
+ if (message.customType === "subagent:steer" || message.customType === "subagent:steer:relay") {
209
+ const details = (
210
+ message as CustomMessage<{
211
+ from?: string;
212
+ to?: string;
213
+ body?: string;
214
+ state?: string;
215
+ }>
216
+ ).details;
217
+ const components: Component[] = [];
218
+ const header = `${theme.fg("accent", `[Steer ${details?.state ?? "queued"}] ${details?.from ?? "?"} ⇨ ${details?.to ?? "?"}`)}`;
219
+ const headerComponent = new Text(header, 1, 0);
220
+ this.ctx.chatContainer.addChild(headerComponent);
221
+ components.push(headerComponent);
222
+ if (details?.body) {
223
+ for (const line of details.body.split("\n")) {
224
+ const lineComponent = new Text(theme.fg("muted", ` ${line}`), 0, 0);
225
+ this.ctx.chatContainer.addChild(lineComponent);
226
+ components.push(lineComponent);
227
+ }
228
+ }
229
+ return components;
230
+ }
208
231
  const renderer = this.ctx.session.extensionRunner?.getMessageRenderer(message.customType);
209
232
  // Both HookMessage and CustomMessage have the same structure, cast for compatibility
210
233
  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.SKC_NOTIFICATIONS === "0") return false;
36
+ if (input.env.SKC_NOTIFICATIONS === "1" || input.env.SKC_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.SKC_NOTIFICATIONS === "0" -> false (hard opt-out)
44
+ * 2) sessionDisabled === true -> false (local /notify off)
45
+ * 3) env.SKC_NOTIFICATIONS === "1" || env.SKC_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.SKC_NOTIFICATIONS === "0") return false;
55
+ if (input.sessionDisabled) return false;
56
+ if (input.env.SKC_NOTIFICATIONS === "1" || input.env.SKC_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
+ }