@gajae-code/coding-agent 0.6.5 → 0.7.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 (127) hide show
  1. package/CHANGELOG.md +29 -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/state-writer.d.ts +2 -0
  19. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +15 -0
  20. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +14 -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/tools/ask-answer-registry.d.ts +13 -0
  47. package/dist/types/tools/index.d.ts +18 -0
  48. package/dist/types/tools/subagent.d.ts +3 -0
  49. package/package.json +7 -7
  50. package/scripts/build-binary.ts +3 -0
  51. package/src/async/job-manager.ts +5 -1
  52. package/src/cli/daemon-cli.ts +122 -0
  53. package/src/cli/notify-cli.ts +274 -0
  54. package/src/cli/setup-cli.ts +173 -84
  55. package/src/cli.ts +2 -0
  56. package/src/commands/daemon.ts +47 -0
  57. package/src/commands/notify.ts +61 -0
  58. package/src/commands/setup.ts +11 -1
  59. package/src/config/model-profile-activation.ts +74 -5
  60. package/src/config/model-profiles.ts +7 -4
  61. package/src/config/model-registry.ts +6 -3
  62. package/src/config/models-config-schema.ts +1 -1
  63. package/src/config/settings-schema.ts +29 -0
  64. package/src/coordinator/contract.ts +3 -0
  65. package/src/coordinator-mcp/server.ts +270 -1
  66. package/src/daemon/builtin.ts +46 -0
  67. package/src/daemon/control-types.ts +65 -0
  68. package/src/daemon/runtime.ts +51 -0
  69. package/src/defaults/gjc/skills/ultragoal/SKILL.md +16 -0
  70. package/src/extensibility/extensions/runner.ts +4 -0
  71. package/src/extensibility/extensions/types.ts +8 -0
  72. package/src/gjc-runtime/deep-interview-recorder.ts +12 -4
  73. package/src/gjc-runtime/state-runtime.ts +18 -4
  74. package/src/gjc-runtime/state-writer.ts +8 -8
  75. package/src/gjc-runtime/ultragoal-guard.ts +57 -2
  76. package/src/gjc-runtime/ultragoal-runtime.ts +105 -19
  77. package/src/gjc-runtime/workflow-manifest.generated.json +27 -2
  78. package/src/gjc-runtime/workflow-manifest.ts +11 -1
  79. package/src/goals/tools/goal-tool.ts +11 -2
  80. package/src/internal-urls/docs-index.generated.ts +6 -5
  81. package/src/main.ts +30 -0
  82. package/src/modes/acp/acp-event-mapper.ts +1 -0
  83. package/src/modes/components/hook-editor.ts +7 -2
  84. package/src/modes/components/oauth-selector.ts +19 -0
  85. package/src/modes/controllers/event-controller.ts +20 -0
  86. package/src/modes/controllers/selector-controller.ts +80 -17
  87. package/src/modes/interactive-mode.ts +6 -2
  88. package/src/modes/runtime-init.ts +1 -0
  89. package/src/modes/shared/agent-wire/event-contract.ts +1 -0
  90. package/src/modes/shared/agent-wire/event-envelope.ts +1 -0
  91. package/src/modes/shared/agent-wire/event-observation.ts +16 -0
  92. package/src/modes/shared/agent-wire/unattended-session.ts +22 -0
  93. package/src/modes/types.ts +7 -1
  94. package/src/modes/utils/ui-helpers.ts +23 -0
  95. package/src/notifications/config-commands.ts +50 -0
  96. package/src/notifications/config.ts +107 -0
  97. package/src/notifications/helpers.ts +135 -0
  98. package/src/notifications/html-format.ts +389 -0
  99. package/src/notifications/index.ts +663 -0
  100. package/src/notifications/rate-limit-pool.ts +179 -0
  101. package/src/notifications/telegram-cli.ts +194 -0
  102. package/src/notifications/telegram-daemon-cli.ts +74 -0
  103. package/src/notifications/telegram-daemon-control.ts +370 -0
  104. package/src/notifications/telegram-daemon.ts +1370 -0
  105. package/src/notifications/telegram-reference.ts +335 -0
  106. package/src/notifications/threaded-inbound.ts +80 -0
  107. package/src/notifications/threaded-render.ts +155 -0
  108. package/src/notifications/topic-registry.ts +133 -0
  109. package/src/rlm/index.ts +19 -0
  110. package/src/sdk.ts +16 -0
  111. package/src/session/agent-session.ts +113 -3
  112. package/src/session/auth-storage.ts +3 -0
  113. package/src/session/session-dump-format.ts +43 -2
  114. package/src/session/session-manager.ts +39 -5
  115. package/src/setup/credential-auto-import.ts +258 -0
  116. package/src/setup/credential-import.ts +17 -0
  117. package/src/setup/hermes/templates/operator-instructions.v1.md +10 -0
  118. package/src/setup/host-plugin-setup.ts +142 -0
  119. package/src/slash-commands/builtin-registry.ts +4 -1
  120. package/src/task/executor.ts +5 -1
  121. package/src/tools/ask-answer-registry.ts +25 -0
  122. package/src/tools/ask.ts +74 -4
  123. package/src/tools/image-gen.ts +5 -8
  124. package/src/tools/index.ts +19 -0
  125. package/src/tools/inspect-image.ts +16 -11
  126. package/src/tools/subagent-render.ts +7 -0
  127. package/src/tools/subagent.ts +38 -7
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Host plugin setup for `gjc setup claude` and `gjc setup codex`.
3
+ *
4
+ * Renders install guidance and a fail-closed coordinator MCP config preview for
5
+ * the canonical generated plugin bundle under `plugins/`. This is intentionally
6
+ * render-only and fail-closed: the workdir allowlist is scoped to the project
7
+ * root and no mutation class is enabled until the user opts in.
8
+ */
9
+
10
+ import * as fs from "node:fs";
11
+ import * as path from "node:path";
12
+ import { getProjectDir } from "@gajae-code/utils";
13
+
14
+ export type HostPluginKind = "claude" | "codex";
15
+
16
+ export interface HostPluginSetupFlags {
17
+ json?: boolean;
18
+ check?: boolean;
19
+ root?: string[];
20
+ repo?: string;
21
+ }
22
+
23
+ export interface HostPluginSetupResult {
24
+ ok: true;
25
+ host: HostPluginKind;
26
+ mode: "render";
27
+ gated: boolean;
28
+ pluginPath: string;
29
+ manifestPath: string;
30
+ marketplacePath: string;
31
+ installGuidance: string[];
32
+ coordinatorConfigPreview: {
33
+ command: string;
34
+ args: string[];
35
+ env: Record<string, string>;
36
+ };
37
+ mutationPolicy: string;
38
+ notes: string[];
39
+ check?: { ok: boolean; checked: string[]; missing: string[] };
40
+ }
41
+
42
+ const NAMESPACE_LABEL = "gajae-code-plugin";
43
+
44
+ function resolveProjectRoot(flags: HostPluginSetupFlags): string {
45
+ const explicit = flags.root?.find(root => root.trim().length > 0);
46
+ return explicit ? path.resolve(explicit) : getProjectDir();
47
+ }
48
+
49
+ function verifyBundleFiles(files: string[]): { ok: boolean; checked: string[]; missing: string[] } {
50
+ const missing = files.filter(file => !fs.existsSync(file));
51
+ return { ok: missing.length === 0, checked: files, missing };
52
+ }
53
+
54
+ export function buildHostPluginSetup(host: HostPluginKind, flags: HostPluginSetupFlags = {}): HostPluginSetupResult {
55
+ const projectRoot = resolveProjectRoot(flags);
56
+ const marketplaceRoot = path.join(projectRoot, "plugins");
57
+ const pluginDir = path.join(marketplaceRoot, "gajae-code");
58
+ const repo = flags.repo && flags.repo.trim().length > 0 ? flags.repo.trim() : NAMESPACE_LABEL;
59
+
60
+ // Concrete, fail-closed env: workdir allowlist is the project root, no mutations.
61
+ const env: Record<string, string> = {
62
+ GJC_COORDINATOR_MCP_WORKDIR_ROOTS: projectRoot,
63
+ GJC_COORDINATOR_MCP_REPO: repo,
64
+ GJC_COORDINATOR_MCP_SESSION_COMMAND: "gjc --worktree",
65
+ };
66
+
67
+ if (host === "claude") {
68
+ const manifestPath = path.join(pluginDir, ".claude-plugin", "plugin.json");
69
+ const marketplacePath = path.join(marketplaceRoot, ".claude-plugin", "marketplace.json");
70
+ return {
71
+ ok: true,
72
+ host,
73
+ mode: "render",
74
+ gated: false,
75
+ pluginPath: marketplaceRoot,
76
+ manifestPath,
77
+ marketplacePath,
78
+ installGuidance: [
79
+ `Add the local marketplace: /plugin marketplace add ${marketplaceRoot}`,
80
+ "Install the plugin: /plugin install gajae-code",
81
+ "Then call gjc_delegate_plan / gjc_delegate_execute / gjc_delegate_team from Claude Code.",
82
+ ],
83
+ coordinatorConfigPreview: { command: "gjc", args: ["mcp-serve", "coordinator"], env },
84
+ mutationPolicy:
85
+ "Fail-closed: delegation is read-only until you set GJC_COORDINATOR_MCP_MUTATIONS=sessions and pass allow_mutation:true per call.",
86
+ notes: [],
87
+ ...(flags.check
88
+ ? { check: verifyBundleFiles([manifestPath, marketplacePath, path.join(pluginDir, ".mcp.json")]) }
89
+ : {}),
90
+ };
91
+ }
92
+
93
+ // Codex: verified installable on Codex CLI 0.139.0 via the local marketplace smoke.
94
+ const manifestPath = path.join(pluginDir, ".codex-plugin", "plugin.json");
95
+ const marketplacePath = path.join(marketplaceRoot, ".agents", "plugins", "marketplace.json");
96
+ return {
97
+ ok: true,
98
+ host,
99
+ mode: "render",
100
+ gated: false,
101
+ pluginPath: marketplaceRoot,
102
+ manifestPath,
103
+ marketplacePath,
104
+ installGuidance: [
105
+ `Add the local marketplace: codex plugin marketplace add ${marketplaceRoot}`,
106
+ "Install the plugin: codex plugin add gajae-code@gajae-code-local",
107
+ "Then call gjc_delegate_plan / gjc_delegate_execute / gjc_delegate_team from Codex.",
108
+ ],
109
+ coordinatorConfigPreview: { command: "gjc", args: ["mcp-serve", "coordinator"], env },
110
+ mutationPolicy:
111
+ "Fail-closed: delegation is read-only until you set GJC_COORDINATOR_MCP_MUTATIONS=sessions and pass allow_mutation:true per call.",
112
+ notes: [
113
+ "Verified on Codex CLI 0.139.0: marketplace add + plugin add install the plugin (enabled) and `codex mcp list` registers gjc-coordinator with the fail-closed env.",
114
+ "The bundled .codex.mcp.json workdir root is host-neutral; `gjc setup codex` renders a concrete root, and operators should re-run the local marketplace smoke on their target Codex version.",
115
+ ],
116
+ ...(flags.check
117
+ ? {
118
+ check: verifyBundleFiles([
119
+ manifestPath,
120
+ marketplacePath,
121
+ path.join(pluginDir, ".codex.mcp.json"),
122
+ path.join(pluginDir, "skills", "gjc-delegation", "SKILL.md"),
123
+ ]),
124
+ }
125
+ : {}),
126
+ };
127
+ }
128
+
129
+ export function formatHostPluginSetup(result: HostPluginSetupResult): string {
130
+ const lines: string[] = [];
131
+ lines.push(`host: ${result.host}${result.gated ? " (gated on versioned smoke)" : ""}`);
132
+ lines.push(`plugin: ${result.pluginPath}`);
133
+ lines.push("install:");
134
+ for (const step of result.installGuidance) lines.push(` - ${step}`);
135
+ lines.push(`mcp: ${result.coordinatorConfigPreview.command} ${result.coordinatorConfigPreview.args.join(" ")}`);
136
+ lines.push(
137
+ ` GJC_COORDINATOR_MCP_WORKDIR_ROOTS=${result.coordinatorConfigPreview.env.GJC_COORDINATOR_MCP_WORKDIR_ROOTS}`,
138
+ );
139
+ lines.push(result.mutationPolicy);
140
+ for (const note of result.notes) lines.push(`note: ${note}`);
141
+ return lines.join("\n");
142
+ }
@@ -797,7 +797,10 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
797
797
  return;
798
798
  }
799
799
 
800
- runtime.ctx.showProviderOnboarding();
800
+ runtime.ctx.showOAuthSelector("login", undefined, {
801
+ allowExternalCredentialDiscovery: true,
802
+ trigger: "bare-login",
803
+ });
801
804
  runtime.ctx.editor.setText("");
802
805
  },
803
806
  },
@@ -1298,11 +1298,15 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1298
1298
  requestPause: () => {
1299
1299
  pauseRequested = true;
1300
1300
  },
1301
- injectMessage: async (content, deliverAs) => {
1301
+ injectMessage: async (content, deliverAs, opts) => {
1302
1302
  if (deliverAs === "nextTurn") {
1303
1303
  await session.prompt(content, { attribution: "agent" });
1304
1304
  return;
1305
1305
  }
1306
+ if (deliverAs === "steer") {
1307
+ const from = opts?.fromAgentId ?? manager.getSubagentRecord(liveSubagentId)?.ownerId ?? "?";
1308
+ session.emitSubagentSteerObservation({ from, to: liveSubagentId, body: content });
1309
+ }
1306
1310
  await session.sendUserMessage(content, { deliverAs });
1307
1311
  },
1308
1312
  });
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Process-wide registry mapping a session id to its active {@link AskAnswerSource}.
3
+ *
4
+ * Decouples the `ask` tool (which reads the source via `AgentSession`) from the
5
+ * notifications extension (which registers one), without threading a new method
6
+ * through the extension/runner/controller wiring. A session has at most one
7
+ * source; registering returns a disposer.
8
+ */
9
+
10
+ import type { AskAnswerSource } from "./index";
11
+
12
+ const sources = new Map<string, AskAnswerSource>();
13
+
14
+ /** Register `source` for `sessionId`. Returns a disposer that clears it. */
15
+ export function registerAskAnswerSource(sessionId: string, source: AskAnswerSource): () => void {
16
+ sources.set(sessionId, source);
17
+ return () => {
18
+ if (sources.get(sessionId) === source) sources.delete(sessionId);
19
+ };
20
+ }
21
+
22
+ /** The answer source for `sessionId`, if one is registered. */
23
+ export function getAskAnswerSource(sessionId: string): AskAnswerSource | undefined {
24
+ return sources.get(sessionId);
25
+ }
package/src/tools/ask.ts CHANGED
@@ -430,8 +430,18 @@ async function askSingleQuestion(
430
430
  // If input was dismissed (undefined), keep prior selectedOptions/customInput intact
431
431
  }
432
432
  } else {
433
- selectedOptions = [stripRecommendedSuffix(choice)];
434
- customInput = undefined;
433
+ const stripped = stripRecommendedSuffix(choice);
434
+ if (optionLabels.includes(stripped)) {
435
+ selectedOptions = [stripped];
436
+ customInput = undefined;
437
+ } else {
438
+ // A remote answer (e.g. a typed Telegram reply) that is not one of the
439
+ // listed options is the "provide my own" custom input — recorded the same
440
+ // as picking Other and typing it. The local selector can only ever return
441
+ // a listed entry, so this branch is reached only for free-text answers.
442
+ customInput = choice;
443
+ selectedOptions = [];
444
+ }
435
445
  }
436
446
  if (navigation?.allowForward) {
437
447
  return { selectedOptions, customInput, timedOut, navigation: "forward" };
@@ -551,11 +561,71 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
551
561
  const ui: UIContext = {
552
562
  select: (prompt, options, dialogOptions) => {
553
563
  if (!extensionUi) throw new ToolAbortError("Ask tool requires interactive mode");
554
- return extensionUi.select(prompt, options, dialogOptions);
564
+ const source = this.session.getAskAnswerSource?.();
565
+ if (!source) return extensionUi.select(prompt, options, dialogOptions);
566
+ // Race the local UI against a remote answer (e.g. a Telegram reply via the
567
+ // notifications SDK) so asks can be answered without RPC mode. When the
568
+ // local UI wins, abort the remote source so it stops waiting and marks the
569
+ // action resolved-locally. First valid answer wins.
570
+ // Race the local UI against a remote answer (e.g. a Telegram reply via the
571
+ // notifications SDK) so asks can be answered without RPC mode. First valid
572
+ // answer wins; the loser is aborted so neither side is left hanging:
573
+ // - local wins -> abort the remote source (marks the action resolved-locally)
574
+ // - remote wins -> abort the local selector so the TUI dialog actually closes
575
+ const remoteController = new AbortController();
576
+ const localController = new AbortController();
577
+ // Propagate an external cancel (the tool's signal) to the local selector too.
578
+ const toolSignal = dialogOptions?.signal;
579
+ if (toolSignal) {
580
+ if (toolSignal.aborted) localController.abort();
581
+ else toolSignal.addEventListener("abort", () => localController.abort(), { once: true });
582
+ }
583
+ const remote = source.awaitAnswer(prompt, options, remoteController.signal).then(answer => {
584
+ // undefined is not a valid remote answer (registration failed, or the local
585
+ // UI already won and aborted us): never settle the race, let the local
586
+ // selector decide instead of cancelling the ask.
587
+ if (answer === undefined) return new Promise<string | undefined>(() => {});
588
+ localController.abort();
589
+ return answer;
590
+ });
591
+ const local = extensionUi
592
+ .select(prompt, options, { ...dialogOptions, signal: localController.signal })
593
+ .then(answer => {
594
+ remoteController.abort();
595
+ return answer;
596
+ });
597
+ // The losing selector may reject when aborted after the race already settled;
598
+ // swallow that so it is not an unhandled rejection (the race result is unaffected).
599
+ void local.catch(() => undefined);
600
+ return Promise.race([local, remote]);
555
601
  },
556
602
  editor: (title, prefill, dialogOptions, editorOptions) => {
557
603
  if (!extensionUi) throw new ToolAbortError("Ask tool requires interactive mode");
558
- return extensionUi.editor(title, prefill, dialogOptions, editorOptions);
604
+ const source = this.session.getAskAnswerSource?.();
605
+ if (!source) return extensionUi.editor(title, prefill, dialogOptions, editorOptions);
606
+ // Race the local editor against a remote free-text answer so "Other / type
607
+ // your own" custom input can be provided remotely (e.g. a typed Telegram
608
+ // reply) instead of blocking on the local-only editor. Mirrors `select`.
609
+ const remoteController = new AbortController();
610
+ const localController = new AbortController();
611
+ const toolSignal = dialogOptions?.signal;
612
+ if (toolSignal) {
613
+ if (toolSignal.aborted) localController.abort();
614
+ else toolSignal.addEventListener("abort", () => localController.abort(), { once: true });
615
+ }
616
+ const remote = source.awaitAnswer(title, [], remoteController.signal).then(answer => {
617
+ if (answer === undefined) return new Promise<string | undefined>(() => {});
618
+ localController.abort();
619
+ return answer;
620
+ });
621
+ const local = extensionUi
622
+ .editor(title, prefill, { ...(dialogOptions ?? {}), signal: localController.signal }, editorOptions)
623
+ .then(answer => {
624
+ remoteController.abort();
625
+ return answer;
626
+ });
627
+ void local.catch(() => undefined);
628
+ return Promise.race([local, remote]);
559
629
  },
560
630
  };
561
631
 
@@ -472,20 +472,17 @@ async function findImageApiKey(
472
472
  const openAI = await findOpenAIHostedImageCredentials(modelRegistry, activeModel, sessionId);
473
473
  if (openAI) return openAI;
474
474
  // Fall through to auto-detect if preferred provider key not found.
475
- } else if (preferredImageProvider === "antigravity" && modelRegistry) {
476
- const antigravity = await findAntigravityCredentials(modelRegistry, sessionId);
477
- if (antigravity) return antigravity;
478
- // Fall through to auto-detect if preferred provider key not found.
475
+ } else if (preferredImageProvider === "antigravity") {
476
+ if (!modelRegistry) return null;
477
+ return await findAntigravityCredentials(modelRegistry, sessionId);
479
478
  } else if (preferredImageProvider === "gemini") {
480
479
  const geminiKey = getEnvApiKey("google");
481
480
  if (geminiKey) return { provider: "gemini", apiKey: geminiKey };
482
481
  const googleKey = $env.GOOGLE_API_KEY;
483
- if (googleKey) return { provider: "gemini", apiKey: googleKey };
484
- // Fall through to auto-detect if preferred provider key not found.
482
+ return googleKey ? { provider: "gemini", apiKey: googleKey } : null;
485
483
  } else if (preferredImageProvider === "openrouter") {
486
484
  const openRouterKey = getEnvApiKey("openrouter");
487
- if (openRouterKey) return { provider: "openrouter", apiKey: openRouterKey };
488
- // Fall through to auto-detect if preferred provider key not found.
485
+ return openRouterKey ? { provider: "openrouter", apiKey: openRouterKey } : null;
489
486
  }
490
487
 
491
488
  // Auto-detect: GPT hosted image generation, then Antigravity, OpenRouter, Gemini.
@@ -118,6 +118,18 @@ export type {
118
118
  DiscoverableToolSource,
119
119
  } from "../tool-discovery/tool-index";
120
120
 
121
+ /**
122
+ * Source of remote answers for interactive asks (e.g. a Telegram reply routed
123
+ * through the notifications SDK). Lets a pending ask resolve without RPC mode.
124
+ */
125
+ export interface AskAnswerSource {
126
+ /**
127
+ * Race a remote answer against the local UI for one question. Resolves with the
128
+ * chosen option label or free-text answer, or `undefined` to defer to local UI.
129
+ */
130
+ awaitAnswer(question: string, options: string[], signal?: AbortSignal): Promise<string | undefined>;
131
+ }
132
+
121
133
  /** Session context for tool factories */
122
134
  export interface ToolSession {
123
135
  /** Current working directory */
@@ -214,6 +226,13 @@ export interface ToolSession {
214
226
  getGoalModeState?: () => GoalModeState | undefined;
215
227
  /** Unattended workflow-gate emitter (present only when unattended mode is negotiated). */
216
228
  getWorkflowGateEmitter?: () => WorkflowGateEmitter | undefined;
229
+ /**
230
+ * Optional remote answer source for interactive asks. When present, the ask
231
+ * tool races the local UI selection against a remote answer (e.g. a Telegram
232
+ * reply via the notifications SDK) so asks can be answered without RPC mode.
233
+ * No-op when undefined: the ask path behaves exactly as before.
234
+ */
235
+ getAskAnswerSource?: () => AskAnswerSource | undefined;
217
236
  /** Optional per-session restriction for goal tool operations. */
218
237
  goalToolAllowedOps?: readonly ("create" | "get" | "complete" | "resume" | "drop" | "pause")[];
219
238
  /** Goal runtime for the active agent session. */
@@ -78,21 +78,26 @@ export class InspectImageTool implements AgentTool<typeof inspectImageSchema, In
78
78
  };
79
79
 
80
80
  const activeModelPattern = this.session.getActiveModelString?.() ?? this.session.getModelString?.();
81
- let model = resolvePattern("pi/default") ?? resolvePattern(activeModelPattern) ?? availableModels[0];
81
+ const configuredVisionPattern = this.session.settings.getModelRole("vision")?.trim();
82
+ const configuredVisionModel = configuredVisionPattern ? resolvePattern("pi/vision") : undefined;
83
+ if (configuredVisionPattern && !configuredVisionModel) {
84
+ throw new ToolError(
85
+ `Configured modelRoles.vision (${configuredVisionPattern}) did not resolve to an available model. Configure modelRoles.vision with a vision-capable model.`,
86
+ );
87
+ }
88
+ const model = configuredVisionModel ?? resolvePattern("pi/default") ?? resolvePattern(activeModelPattern);
82
89
  if (!model) {
83
- throw new ToolError("Unable to resolve a model for inspect_image.");
90
+ throw new ToolError(
91
+ "Unable to resolve a model for inspect_image. Configure modelRoles.vision with a vision-capable model or select a vision-capable active/default model.",
92
+ );
84
93
  }
85
94
 
86
- // inspect_image requires image input; if the resolved model is text-only,
87
- // fall back to any available vision-capable model before failing.
95
+ // inspect_image requires image input. A text-only selected model must be
96
+ // paired with an explicit vision role so the model/cost boundary is visible.
88
97
  if (!model.input.includes("image")) {
89
- const visionModel = availableModels.find(candidate => candidate.input.includes("image"));
90
- if (!visionModel) {
91
- throw new ToolError(
92
- `Resolved model ${model.provider}/${model.id} does not support image input, and no vision-capable model is available. Configure a vision-capable model.`,
93
- );
94
- }
95
- model = visionModel;
98
+ throw new ToolError(
99
+ `Resolved model ${model.provider}/${model.id} does not support image input. Configure modelRoles.vision with a vision-capable model.`,
100
+ );
96
101
  }
97
102
 
98
103
  const apiKey = await modelRegistry.getApiKey(model);
@@ -160,6 +160,13 @@ function renderSubagentSnapshotBody(snapshot: SubagentSnapshot, expanded: boolea
160
160
  lines.push(` ${theme.fg("dim", "Assignment:")}`);
161
161
  for (const al of snapshot.assignment.split("\n")) lines.push(` ${theme.fg("toolOutput", replaceTabs(al))}`);
162
162
  }
163
+ if (snapshot.steerMessage) {
164
+ lines.push(` ${theme.fg("accent", `Steer (${snapshot.steerState ?? "queued"})`)}`);
165
+ const maxLines = expanded ? PREVIEW_LINES_EXPANDED : PREVIEW_LINES_COLLAPSED;
166
+ for (const pl of getPreviewLines(snapshot.steerMessage, maxLines, PREVIEW_LINE_WIDTH, Ellipsis.Unicode)) {
167
+ lines.push(` ${theme.fg("toolOutput", replaceTabs(pl))}`);
168
+ }
169
+ }
163
170
 
164
171
  // Defense in depth: the producer only attaches `progress` when a live producer
165
172
  // exists (subagent.ts #liveProgressFields), but the renderer also honors an
@@ -17,6 +17,8 @@ const MAX_LIST_LIMIT = 50;
17
17
  const RECEIPT_PREVIEW_WIDTH = 280;
18
18
  const PREVIEW_WIDTH = 2_000;
19
19
  const FULL_PREVIEW_WIDTH = 12_000;
20
+ const STEER_QUEUED_GUIDANCE =
21
+ "The steer message is queued for the subagent's next steering boundary and has not necessarily taken effect yet.";
20
22
 
21
23
  const subagentSchema = z.object({
22
24
  action: z
@@ -63,6 +65,9 @@ export interface SubagentSnapshot {
63
65
  outputRef?: string;
64
66
  truncated?: boolean;
65
67
  guidance?: string;
68
+ steerMessage?: string;
69
+ steerState?: "queued" | "resume_queued" | "resume_started";
70
+ steerPauseRequested?: boolean;
66
71
  /** Live streaming progress for the awaited subagent (await panel only; UI detail). */
67
72
  progress?: AgentProgress;
68
73
  /** True when a live in-session progress producer exists for this subagent. */
@@ -240,6 +245,7 @@ export class SubagentTool implements AgentTool<typeof subagentSchema, SubagentTo
240
245
  }
241
246
  const records: SubagentRecord[] = [];
242
247
  const missing: SubagentSnapshot[] = [];
248
+ const steerStates = new Map<string, NonNullable<SubagentSnapshot["steerState"]>>();
243
249
  const record = this.#findVisibleRecord(manager, id, ownerFilter);
244
250
  const verifiedOutputIds = await this.#verifiedOutputIds(record ? [record] : []);
245
251
  if (!record) {
@@ -249,23 +255,41 @@ export class SubagentTool implements AgentTool<typeof subagentSchema, SubagentTo
249
255
  if (record.status === "running") {
250
256
  const handle = manager.getLiveHandle(record.subagentId);
251
257
  if (!handle) throw new ToolError(`Subagent ${record.subagentId} has no live handle.`);
252
- await handle.injectMessage(message, "steer");
258
+ const fromAgentId = this.session.getAgentId?.() ?? undefined;
259
+ await handle.injectMessage(message, "steer", { fromAgentId });
253
260
  if (params.pause === true) manager.pauseSubagent(record.subagentId, ownerFilter);
261
+ records.push(manager.getSubagentRecord(record.subagentId, ownerFilter) ?? record);
262
+ steerStates.set(record.subagentId, "queued");
254
263
  } else {
255
264
  const result = manager.resumeSubagent(record.subagentId, ownerFilter, message);
256
- if (!result.ok && result.reason === "context_unavailable") throw new ToolError("context unavailable");
257
265
  if (!result.ok && result.reason === "not_found") {
258
266
  missing.push(this.#missingSnapshot(id, "not_found", "No visible detached subagent matches this id."));
267
+ } else if (!result.ok) {
268
+ throw new ToolError(`Failed to resume subagent ${record.subagentId}: ${result.reason ?? "unknown"}.`);
259
269
  } else {
260
- records.push(manager.getSubagentRecord(record.subagentId, ownerFilter) ?? record);
270
+ const snapshotRecord = manager.getSubagentRecord(record.subagentId, ownerFilter) ?? record;
271
+ records.push(snapshotRecord);
272
+ steerStates.set(
273
+ snapshotRecord.subagentId,
274
+ result.queued === true || result.status === "queued" ? "resume_queued" : "resume_started",
275
+ );
261
276
  }
262
277
  }
263
- if (record.status === "running")
264
- records.push(manager.getSubagentRecord(record.subagentId, ownerFilter) ?? record);
265
278
  }
266
279
  return this.#buildSnapshotResult(
267
280
  [
268
- ...records.map(record => this.#recordSnapshot(manager, record, false, verbosity, verifiedOutputIds)),
281
+ ...records.map(record => {
282
+ const snapshot = this.#recordSnapshot(manager, record, false, verbosity, verifiedOutputIds);
283
+ return {
284
+ ...snapshot,
285
+ steerMessage: message,
286
+ steerState: steerStates.get(record.subagentId) ?? "queued",
287
+ steerPauseRequested: params.pause === true,
288
+ guidance: snapshot.guidance
289
+ ? `${snapshot.guidance} ${STEER_QUEUED_GUIDANCE}`
290
+ : STEER_QUEUED_GUIDANCE,
291
+ };
292
+ }),
269
293
  ...missing,
270
294
  ],
271
295
  "Subagent steer",
@@ -288,7 +312,7 @@ export class SubagentTool implements AgentTool<typeof subagentSchema, SubagentTo
288
312
  if (ids.length === 1) return ids[0]!;
289
313
  if (ids.length > 1) {
290
314
  throw new ToolError(
291
- `\`${action}\` accepts exactly one target because \`message\` is delivered to one subagent.`,
315
+ `\`${action}\` accepts exactly one target because \`message\` can be queued for only one subagent.`,
292
316
  );
293
317
  }
294
318
  throw new ToolError(`\`${action}\` requires a single subagent id via \`id\`.`);
@@ -533,6 +557,10 @@ export class SubagentTool implements AgentTool<typeof subagentSchema, SubagentTo
533
557
  if (snapshot.description) lines.push(`Description: ${snapshot.description}`);
534
558
  if (snapshot.outputRef) lines.push(`Output: ${snapshot.outputRef}`);
535
559
  if (snapshot.assignment) lines.push("Assignment:", "```", snapshot.assignment, "```");
560
+ if (snapshot.steerMessage) {
561
+ lines.push(`Steer (${snapshot.steerState ?? "queued"}):`, "```", snapshot.steerMessage, "```");
562
+ lines.push(STEER_QUEUED_GUIDANCE);
563
+ }
536
564
  if (snapshot.resultPreview) {
537
565
  lines.push(snapshot.errorText ? "Error preview:" : "Result preview:", "```", snapshot.resultPreview, "```");
538
566
  if (snapshot.truncated)
@@ -770,6 +798,9 @@ function canonicalizeSnapshotForSignature(snapshot: SubagentSnapshot): unknown {
770
798
  outputRef: snapshot.outputRef ?? null,
771
799
  truncated: snapshot.truncated ?? false,
772
800
  guidance: snapshot.guidance ?? null,
801
+ steerMessage: snapshot.steerMessage ?? null,
802
+ steerState: snapshot.steerState ?? null,
803
+ steerPauseRequested: snapshot.steerPauseRequested ?? false,
773
804
  liveProgressAvailable: snapshot.liveProgressAvailable ?? null,
774
805
  effectiveModel: snapshot.effectiveModel ?? null,
775
806
  requestedModel: snapshot.requestedModel ?? null,