@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
@@ -898,32 +898,29 @@ function categorizeComputerChangePath(value: string): UltragoalChangeCategory {
898
898
  return "other";
899
899
  }
900
900
 
901
- function isComputerChangePath(row: UltragoalChangeSetPath): boolean {
901
+ function isComputerControlSurfaceCategory(category: UltragoalChangeCategory): boolean {
902
902
  return (
903
- categorizeComputerChangePath(row.path) !== "other" ||
904
- (row.oldPath ? categorizeComputerChangePath(row.oldPath) !== "other" : false)
903
+ category === "code" || category === "generated-binding" || category === "tool" || category === "settings-registry"
905
904
  );
906
905
  }
907
906
 
908
- function isDocsOnlyStaticComputerChangeSet(changeSet: UltragoalChangeSet | undefined): boolean {
909
- if (!changeSet || changeSet.paths.length === 0) return false;
910
- return changeSet.paths.every(row => {
911
- const category = row.category ?? categorizeComputerChangePath(row.path);
912
- const oldCategory = row.oldPath ? categorizeComputerChangePath(row.oldPath) : category;
913
- return category === "docs-static" && oldCategory === "docs-static";
914
- });
907
+ function isComputerControlSurfaceChangePath(row: UltragoalChangeSetPath): boolean {
908
+ const category = row.category ?? categorizeComputerChangePath(row.path);
909
+ const oldCategory = row.oldPath ? categorizeComputerChangePath(row.oldPath) : category;
910
+ return isComputerControlSurfaceCategory(category) || isComputerControlSurfaceCategory(oldCategory);
915
911
  }
916
912
 
917
913
  function trustedChangeSetRequiresComputerSuite(changeSet: UltragoalChangeSet | undefined): boolean {
918
914
  if (!changeSet?.trusted) return false;
919
- if (isDocsOnlyStaticComputerChangeSet(changeSet)) return false;
920
- return changeSet.paths.some(isComputerChangePath);
915
+ return changeSet.paths.some(isComputerControlSurfaceChangePath);
921
916
  }
922
917
 
923
918
  function requiresComputerRedTeamSuite(executorQa: JsonObject, changeSet: UltragoalChangeSet | undefined): boolean {
924
919
  if (trustedChangeSetRequiresComputerSuite(changeSet)) return true;
925
920
  const declaredPaths = Array.isArray(executorQa.changedPaths) ? executorQa.changedPaths : [];
926
- return declaredPaths.some(value => typeof value === "string" && categorizeComputerChangePath(value) !== "other");
921
+ return declaredPaths.some(
922
+ value => typeof value === "string" && isComputerControlSurfaceCategory(categorizeComputerChangePath(value)),
923
+ );
927
924
  }
928
925
 
929
926
  function normalizeAdversarialCaseId(value: string): string {
@@ -2853,6 +2850,33 @@ export async function recordUltragoalReviewBlockers(input: {
2853
2850
  return plan;
2854
2851
  }
2855
2852
 
2853
+ export type UltragoalBlockerClassification = "human_blocked" | "resolvable";
2854
+
2855
+ /**
2856
+ * Record an audited blocker triage classification in the durable ledger. A
2857
+ * `human_blocked` classification is the only thing that authorizes
2858
+ * `goal({"op":"pause"})` while an Ultragoal run is active; `resolvable` is an
2859
+ * audit note and never unblocks pause.
2860
+ */
2861
+ export async function recordUltragoalBlockerClassification(input: {
2862
+ cwd: string;
2863
+ classification: UltragoalBlockerClassification;
2864
+ evidence: string;
2865
+ goalId?: string;
2866
+ }): Promise<UltragoalLedgerEvent> {
2867
+ const evidence = input.evidence.trim();
2868
+ if (!evidence) throw new Error("classify-blocker --evidence is required");
2869
+ if (input.classification !== "human_blocked" && input.classification !== "resolvable") {
2870
+ throw new Error('classify-blocker --classification must be "human_blocked" or "resolvable"');
2871
+ }
2872
+ return appendLedger(input.cwd, {
2873
+ event: "blocker_classified",
2874
+ classification: input.classification,
2875
+ ...(input.goalId?.trim() ? { goalId: input.goalId.trim() } : {}),
2876
+ evidence,
2877
+ });
2878
+ }
2879
+
2856
2880
  type UltragoalReviewMode = "review-only" | "review-start";
2857
2881
  type UltragoalReviewContractStrength = "strong" | "thin-derived";
2858
2882
 
@@ -2920,11 +2944,33 @@ async function spawnText(
2920
2944
  }
2921
2945
  }
2922
2946
 
2923
- async function resolveGitBase(cwd: string, branch?: string): Promise<string> {
2924
- const candidates = branch ? [branch] : ["origin/main", "origin/master", "main", "master"];
2925
- for (const candidate of candidates) {
2926
- const exists = await spawnText(["git", "rev-parse", "--verify", candidate], { cwd, timeoutMs: 3000 });
2927
- if (exists.ok) return candidate;
2947
+ export async function resolveGitBase(cwd: string, branch?: string): Promise<string> {
2948
+ if (branch) {
2949
+ const exists = await spawnText(["git", "rev-parse", "--verify", branch], { cwd, timeoutMs: 3000 });
2950
+ if (exists.ok) return branch;
2951
+ } else {
2952
+ // Prefer the NEAREST integration base (the branch this work actually forks
2953
+ // from) rather than always `main`. A branch opened against `dev` must be
2954
+ // scoped to `dev`; using a stale `main` sweeps in unrelated trunk history
2955
+ // and mis-attributes other people's changes to this story (e.g. falsely
2956
+ // tripping change-scoped gates). Among existing candidates, pick the one
2957
+ // whose merge-base with HEAD is closest to HEAD (fewest commits ahead).
2958
+ const candidates = ["origin/dev", "dev", "origin/main", "origin/master", "main", "master"];
2959
+ let best: { ref: string; ahead: number } | undefined;
2960
+ for (const candidate of candidates) {
2961
+ const exists = await spawnText(["git", "rev-parse", "--verify", candidate], { cwd, timeoutMs: 3000 });
2962
+ if (!exists.ok) continue;
2963
+ const mergeBase = await spawnText(["git", "merge-base", "HEAD", candidate], { cwd, timeoutMs: 3000 });
2964
+ if (!mergeBase.ok || !mergeBase.stdout.trim()) continue;
2965
+ const count = await spawnText(["git", "rev-list", "--count", `${mergeBase.stdout.trim()}..HEAD`], {
2966
+ cwd,
2967
+ timeoutMs: 3000,
2968
+ });
2969
+ const ahead = Number.parseInt(count.stdout.trim(), 10);
2970
+ if (!Number.isFinite(ahead)) continue;
2971
+ if (!best || ahead < best.ahead) best = { ref: candidate, ahead };
2972
+ }
2973
+ if (best) return best.ref;
2928
2974
  }
2929
2975
  const mergeBase = await spawnText(["git", "merge-base", "HEAD", "origin/main"], { cwd, timeoutMs: 3000 });
2930
2976
  if (mergeBase.ok && mergeBase.stdout.trim()) return mergeBase.stdout.trim();
@@ -3245,6 +3291,7 @@ const FLAGS_WITH_VALUES = new Set([
3245
3291
  "--rationale",
3246
3292
  "--replacements-json",
3247
3293
  "--order-json",
3294
+ "--classification",
3248
3295
  ]);
3249
3296
 
3250
3297
  function isHelpArg(arg: string): boolean {
@@ -3319,6 +3366,25 @@ function renderUltragoalHelp(args: readonly string[]): string | null {
3319
3366
  "",
3320
3367
  ].join("\n");
3321
3368
  }
3369
+ if (subject === "classify-blocker") {
3370
+ return [
3371
+ "Run native SKC Ultragoal workflow commands",
3372
+ "",
3373
+ "USAGE",
3374
+ " $ skc ultragoal classify-blocker --classification <human_blocked|resolvable> --evidence <text> [FLAGS]",
3375
+ "",
3376
+ "FLAGS",
3377
+ " --classification=<value> Required. human_blocked authorizes pause only as the latest ledger event; resolvable never authorizes pause",
3378
+ " --evidence=<value> Required. Specific blocker evidence; must name the human-only dependency for human_blocked",
3379
+ " --goal-id=<value> Optional durable .skc/ultragoal goal id, e.g. G001",
3380
+ " --json Output a machine-readable receipt",
3381
+ "",
3382
+ "EXAMPLES",
3383
+ ' $ skc ultragoal classify-blocker --classification resolvable --evidence "failing test can be fixed autonomously"',
3384
+ ' $ skc ultragoal classify-blocker --classification human_blocked --evidence "user must provide production API credentials" --goal-id G001',
3385
+ "",
3386
+ ].join("\n");
3387
+ }
3322
3388
  return [
3323
3389
  "Run native SKC Ultragoal workflow commands",
3324
3390
  "",
@@ -3333,8 +3399,9 @@ function renderUltragoalHelp(args: readonly string[]): string | null {
3333
3399
  " review",
3334
3400
  " steer",
3335
3401
  " record-review-blockers",
3402
+ " classify-blocker",
3336
3403
  "",
3337
- "Run `skc ultragoal checkpoint --help` or `skc ultragoal review --help` for command-specific requirements.",
3404
+ "Run `skc ultragoal checkpoint --help`, `skc ultragoal review --help`, or `skc ultragoal classify-blocker --help` for command-specific requirements.",
3338
3405
  "",
3339
3406
  ].join("\n");
3340
3407
  }
@@ -3653,6 +3720,24 @@ async function dispatchUltragoalCommand(args: string[], cwd: string): Promise<Ul
3653
3720
  : "Recorded review blockers.\n",
3654
3721
  };
3655
3722
  }
3723
+ case "classify-blocker": {
3724
+ const event = await recordUltragoalBlockerClassification({
3725
+ cwd,
3726
+ classification: (flagValue(args, "--classification") ?? "") as UltragoalBlockerClassification,
3727
+ evidence: flagValue(args, "--evidence") ?? "",
3728
+ goalId: flagValue(args, "--goal-id"),
3729
+ });
3730
+ return {
3731
+ status: 0,
3732
+ stdout: json
3733
+ ? renderCliWriteReceipt({
3734
+ ok: true,
3735
+ event: "blocker_classified",
3736
+ classification: event.classification,
3737
+ })
3738
+ : `Recorded blocker classification: ${String(event.classification)}.\n`,
3739
+ };
3740
+ }
3656
3741
  default:
3657
3742
  return { status: 1, stderr: `Unknown skc ultragoal command: ${command}\n` };
3658
3743
  }
@@ -3670,6 +3755,7 @@ const RECONCILE_COMMANDS = new Set([
3670
3755
  "steer",
3671
3756
  "record-review-blockers",
3672
3757
  "review",
3758
+ "classify-blocker",
3673
3759
  ]);
3674
3760
 
3675
3761
  /**
@@ -1443,7 +1443,8 @@
1443
1443
  "appliesToVerbs": [
1444
1444
  "checkpoint",
1445
1445
  "record-review-blockers",
1446
- "steer"
1446
+ "steer",
1447
+ "classify-blocker"
1447
1448
  ],
1448
1449
  "name": "evidence",
1449
1450
  "required": true,
@@ -1471,6 +1472,25 @@
1471
1472
  "name": "goal-id",
1472
1473
  "type": "string"
1473
1474
  },
1475
+ {
1476
+ "appliesToVerbs": [
1477
+ "classify-blocker"
1478
+ ],
1479
+ "name": "goal-id",
1480
+ "type": "string"
1481
+ },
1482
+ {
1483
+ "appliesToVerbs": [
1484
+ "classify-blocker"
1485
+ ],
1486
+ "enumValues": [
1487
+ "human_blocked",
1488
+ "resolvable"
1489
+ ],
1490
+ "name": "classification",
1491
+ "required": true,
1492
+ "type": "enum"
1493
+ },
1474
1494
  {
1475
1495
  "appliesToVerbs": [
1476
1496
  "steer"
@@ -1570,7 +1590,8 @@
1570
1590
  "review",
1571
1591
  "checkpoint",
1572
1592
  "record-review-blockers",
1573
- "steer"
1593
+ "steer",
1594
+ "classify-blocker"
1574
1595
  ],
1575
1596
  "name": "json",
1576
1597
  "type": "boolean"
@@ -1651,6 +1672,10 @@
1651
1672
  "name": "steer",
1652
1673
  "surface": "command-positional"
1653
1674
  },
1675
+ {
1676
+ "name": "classify-blocker",
1677
+ "surface": "command-positional"
1678
+ },
1654
1679
  {
1655
1680
  "name": "graph",
1656
1681
  "planned": true,
@@ -237,6 +237,7 @@ export const WORKFLOW_MANIFEST: Record<CanonicalSkcWorkflowSkill, SkillManifest>
237
237
  "review",
238
238
  "record-review-blockers",
239
239
  "steer",
240
+ "classify-blocker",
240
241
  ]),
241
242
  ...plannedVerbs(PLANNED_ADMIN_VERBS),
242
243
  ],
@@ -263,11 +264,19 @@ export const WORKFLOW_MANIFEST: Record<CanonicalSkcWorkflowSkill, SkillManifest>
263
264
  name: "evidence",
264
265
  type: "string",
265
266
  required: true,
266
- appliesToVerbs: ["checkpoint", "record-review-blockers", "steer"],
267
+ appliesToVerbs: ["checkpoint", "record-review-blockers", "steer", "classify-blocker"],
267
268
  },
268
269
  { name: "skc-goal-json", type: "string", appliesToVerbs: ["checkpoint", "record-review-blockers"] },
269
270
  { name: "quality-gate-json", type: "string", appliesToVerbs: ["checkpoint"] },
270
271
  { name: "goal-id", type: "string", appliesToVerbs: ["steer"] },
272
+ { name: "goal-id", type: "string", appliesToVerbs: ["classify-blocker"] },
273
+ {
274
+ name: "classification",
275
+ type: "enum",
276
+ enumValues: ["human_blocked", "resolvable"],
277
+ required: true,
278
+ appliesToVerbs: ["classify-blocker"],
279
+ },
271
280
  {
272
281
  name: "kind",
273
282
  type: "enum",
@@ -307,6 +316,7 @@ export const WORKFLOW_MANIFEST: Record<CanonicalSkcWorkflowSkill, SkillManifest>
307
316
  "checkpoint",
308
317
  "record-review-blockers",
309
318
  "steer",
319
+ "classify-blocker",
310
320
  ],
311
321
  },
312
322
  { name: "directive-json", type: "string", appliesToVerbs: ["steer"], planned: true },
@@ -798,7 +798,10 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
798
798
  return;
799
799
  }
800
800
 
801
- runtime.ctx.showProviderOnboarding();
801
+ runtime.ctx.showOAuthSelector("login", undefined, {
802
+ allowExternalCredentialDiscovery: true,
803
+ trigger: "bare-login",
804
+ });
802
805
  runtime.ctx.editor.setText("");
803
806
  },
804
807
  },
@@ -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
 
@@ -605,8 +675,9 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
605
675
  }
606
676
  try {
607
677
  const deepInterviewPrompt = formatDeepInterviewSelectorPrompt(q.question);
678
+ const isDeepInterviewQuestion = deepInterviewPrompt !== null || q.deepInterview !== undefined;
608
679
  const displayQuestion = deepInterviewPrompt ?? q.question;
609
- const shouldNumberOptions = isDeepInterviewAskQuestion(q.question);
680
+ const shouldNumberOptions = isDeepInterviewQuestion || isDeepInterviewAskQuestion(q.question);
610
681
  const optionLabels = shouldNumberOptions ? numberOptionLabels(rawOptionLabels) : rawOptionLabels;
611
682
  const initialSelection =
612
683
  shouldNumberOptions && options?.previous
@@ -630,7 +701,7 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
630
701
  signal,
631
702
  initialSelection,
632
703
  navigation: options?.navigation,
633
- scrollTitleRows: deepInterviewPrompt === null ? undefined : DEEP_INTERVIEW_SELECTOR_SCROLL_TITLE_ROWS,
704
+ scrollTitleRows: isDeepInterviewQuestion ? DEEP_INTERVIEW_SELECTOR_SCROLL_TITLE_ROWS : undefined,
634
705
  otherOptionLabel: shouldNumberOptions
635
706
  ? formatNumberedOptionLabel(OTHER_OPTION, optionLabels.length)
636
707
  : undefined,
@@ -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