@gajae-code/coding-agent 0.12.12 → 0.12.13

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 (50) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/types/cli/marketplace-hint.d.ts +28 -0
  3. package/dist/types/cli.d.ts +10 -0
  4. package/dist/types/config/file-lock-gc.d.ts +13 -1
  5. package/dist/types/config/model-registry.d.ts +6 -0
  6. package/dist/types/config/models-config-schema.d.ts +99 -0
  7. package/dist/types/gjc-runtime/deep-interview-state.d.ts +14 -0
  8. package/dist/types/gjc-runtime/gc-runtime.d.ts +11 -0
  9. package/dist/types/modes/acp/acp-agent.d.ts +24 -1
  10. package/dist/types/sdk/bus/html-format.d.ts +10 -0
  11. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +6 -2
  12. package/dist/types/sdk/bus/telegram-daemon.d.ts +15 -1
  13. package/dist/types/sdk/bus/telegram-reference.d.ts +10 -0
  14. package/dist/types/session/agent-session.d.ts +0 -2
  15. package/dist/types/session/session-manager.d.ts +17 -1
  16. package/dist/types/tools/index.d.ts +7 -0
  17. package/package.json +8 -8
  18. package/src/cli/fast-help.ts +1 -0
  19. package/src/cli/marketplace-hint.ts +39 -0
  20. package/src/cli/plugin-cli.ts +10 -0
  21. package/src/cli.ts +23 -0
  22. package/src/config/file-lock-gc.ts +47 -26
  23. package/src/config/model-profiles.ts +18 -18
  24. package/src/config/model-registry.ts +17 -5
  25. package/src/config/models-config-schema.ts +9 -4
  26. package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
  27. package/src/gjc-runtime/deep-interview-recorder.ts +9 -5
  28. package/src/gjc-runtime/deep-interview-state.ts +27 -3
  29. package/src/gjc-runtime/gc-render.ts +8 -1
  30. package/src/gjc-runtime/gc-runtime.ts +15 -1
  31. package/src/internal-urls/docs-index.generated.ts +4 -4
  32. package/src/main.ts +11 -3
  33. package/src/modes/acp/acp-agent.ts +123 -7
  34. package/src/modes/components/model-selector.ts +52 -5
  35. package/src/modes/print-mode.ts +10 -0
  36. package/src/prompts/system/project-prompt.md +19 -0
  37. package/src/sdk/bus/html-format.ts +19 -3
  38. package/src/sdk/bus/index.ts +21 -12
  39. package/src/sdk/bus/telegram-daemon-contract.ts +6 -2
  40. package/src/sdk/bus/telegram-daemon.ts +50 -16
  41. package/src/sdk/bus/telegram-reference.ts +72 -9
  42. package/src/sdk/session.ts +5 -1
  43. package/src/session/agent-session.ts +0 -4
  44. package/src/session/internal/managed-session-storage.ts +24 -2
  45. package/src/session/session-manager.ts +77 -22
  46. package/src/setup/provider-presets.json +3 -3
  47. package/src/thinking-metadata.ts +2 -1
  48. package/src/tools/ask.ts +10 -0
  49. package/src/tools/index.ts +7 -0
  50. package/src/tools/todo-write.ts +4 -4
package/src/main.ts CHANGED
@@ -61,11 +61,13 @@ import { SessionMigrationBusyError } from "./session/internal/session-open-error
61
61
  import {
62
62
  type ResumeSessionIdentity,
63
63
  resolveResumableSession,
64
+ SESSION_OVERSIZED_RECOVERY_MESSAGE,
64
65
  SessionArtifactCapacityError,
65
66
  type SessionDestination,
66
67
  type SessionDirectoryMigrationPolicy,
67
68
  type SessionInfo,
68
69
  SessionManager,
70
+ SessionTranscriptOversizedError,
69
71
  type StrictSessionOpenResult,
70
72
  } from "./session/session-manager";
71
73
  import { runStartupCredentialAutoImportIfNeeded } from "./setup/credential-auto-import";
@@ -620,10 +622,13 @@ function operatorFacingSessionOpenMessage(value: unknown): string | undefined {
620
622
  ? value.code
621
623
  : value instanceof SessionMigrationBusyError
622
624
  ? value.code
623
- : typeof value === "string"
624
- ? value
625
- : undefined;
625
+ : value instanceof SessionTranscriptOversizedError
626
+ ? value.code
627
+ : typeof value === "string"
628
+ ? value
629
+ : undefined;
626
630
  if (code === "artifact_capacity_exceeded") return SESSION_ARTIFACT_CAPACITY_RECOVERY_MESSAGE;
631
+ if (code === "oversized") return SESSION_OVERSIZED_RECOVERY_MESSAGE;
627
632
  if (code === "migration_busy") return new SessionMigrationBusyError().message;
628
633
  return undefined;
629
634
  }
@@ -1834,6 +1839,9 @@ export async function runRootCommand(
1834
1839
  } catch {
1835
1840
  logger.warn("Failed to dispose session after interactive error");
1836
1841
  }
1842
+ if (error !== null && typeof error === "object" && "code" in error && error.code === "cancelled") {
1843
+ return;
1844
+ }
1837
1845
  throw error;
1838
1846
  }
1839
1847
 
@@ -403,8 +403,8 @@ function receivedSdkEvent(frame: JsonObject): ReceivedSdkEvent | undefined {
403
403
  }
404
404
 
405
405
  const ACP_CONFIG_OPTIONS = [
406
- { id: MODEL_CONFIG_ID, name: "Model", options: [] },
407
- { id: THINKING_CONFIG_ID, name: "Thinking", options: [] },
406
+ { id: MODEL_CONFIG_ID, name: "Model", category: "model", options: [] },
407
+ { id: THINKING_CONFIG_ID, name: "Thinking", category: "thought_level", options: [] },
408
408
  {
409
409
  id: "steeringMode",
410
410
  name: "Steering queue",
@@ -466,17 +466,122 @@ function modelPresetConfigOptions(query: unknown, current: string): { value: str
466
466
  return [...options].map(([value, name]) => ({ value, name }));
467
467
  }
468
468
 
469
- function modelConfigOptions(query: unknown, current: string | undefined): { value: string; name: string }[] {
469
+ function modelConfigOptions(
470
+ query: unknown,
471
+ current: string | undefined,
472
+ activeProviders?: ReadonlySet<string>,
473
+ ): { value: string; name: string }[] {
470
474
  const options = new Map<string, string>();
471
475
  for (const item of pageItems(query)) {
472
476
  const model = object(item);
473
477
  if (!model || typeof model.provider !== "string" || typeof model.id !== "string") continue;
478
+ if (activeProviders !== undefined && !activeProviders.has(model.provider)) continue;
474
479
  const value = `${model.provider}/${model.id}`;
475
480
  options.set(value, typeof model.name === "string" ? model.name : value);
476
481
  }
477
482
  if (current && !options.has(current)) options.set(current, current);
478
483
  return [...options].map(([value, name]) => ({ value, name }));
479
484
  }
485
+ const MAX_ACTIVE_PROVIDER_PAGES = 100;
486
+
487
+ /**
488
+ * Unsupported-query compatibility fallback. Session hosts without
489
+ * `providers.list/active` reject the unknown named query as either
490
+ * `operation_not_session_owned` (host knows the registry but not the query)
491
+ * or `invalid_request` (pre-Q29 host that predates the registry entry). Both
492
+ * keep the full catalog authoritative on the first page; every other failure
493
+ * mode fails closed so the active-provider contract is never silently
494
+ * widened.
495
+ */
496
+ function isUnsupportedQueryError(error: unknown): boolean {
497
+ return (
498
+ typeof error === "object" &&
499
+ error !== null &&
500
+ ((error as { code?: unknown }).code === "operation_not_session_owned" ||
501
+ (error as { code?: unknown }).code === "invalid_request")
502
+ );
503
+ }
504
+
505
+ function queryPage(query: unknown): { items?: unknown; complete?: unknown; continuationCursor?: unknown } | undefined {
506
+ const response = object(query);
507
+ const result = object(response?.result) ?? response;
508
+ return object(result?.page);
509
+ }
510
+
511
+ /**
512
+ * Collect every page of `providers.list/active` (Q29, GJC >= 0.12.8) and
513
+ * return the providers with usable stored credentials or credentialless
514
+ * connection kinds, mirroring the TUI model picker's
515
+ * `modelRegistry.getAvailable()`. The openwebui-gjc-adapter applies the same
516
+ * filter to `/v1/models`. Q29 pages are byte-bounded and can span multiple
517
+ * pages when custom provider ids inflate the payload, so all pages are
518
+ * consumed before the provider set is built. Returns undefined only when the
519
+ * session host rejects the query with `operation_not_session_owned`; any
520
+ * other query failure or malformed page is thrown.
521
+ */
522
+ export async function collectActiveProviderIds(
523
+ adapter: Pick<AcpSdkAdapter, "query">,
524
+ ): Promise<ReadonlySet<string> | undefined> {
525
+ const providers = new Set<string>();
526
+ let cursor: string | undefined;
527
+ for (let pageCount = 0; pageCount < MAX_ACTIVE_PROVIDER_PAGES; pageCount++) {
528
+ let response: unknown;
529
+ try {
530
+ response = await adapter.query("providers.list/active", {}, cursor);
531
+ } catch (error) {
532
+ if (cursor === undefined && isUnsupportedQueryError(error)) return undefined;
533
+ throw error;
534
+ }
535
+ const page = queryPage(response);
536
+ if (!page) throw new AcpSdkAdapterError("protocol_error", "providers.list/active returned no page.");
537
+ const items = page.items;
538
+ if (!Array.isArray(items))
539
+ throw new AcpSdkAdapterError("protocol_error", "providers.list/active returned a malformed page.");
540
+ for (const item of items) {
541
+ const record = object(item);
542
+ if (!record || typeof record.provider !== "string") continue;
543
+ const connectionKind = record.connectionKind;
544
+ if (connectionKind === "credential" || connectionKind === "credentialless") providers.add(record.provider);
545
+ }
546
+ if (page.complete === true) return providers;
547
+ if (typeof page.continuationCursor !== "string")
548
+ throw new AcpSdkAdapterError(
549
+ "protocol_error",
550
+ "providers.list/active page is incomplete without a continuation cursor.",
551
+ );
552
+ cursor = page.continuationCursor;
553
+ }
554
+ throw new AcpSdkAdapterError("protocol_error", "providers.list/active exceeded the page budget.");
555
+ }
556
+ const MAX_MODEL_CATALOG_PAGES = 100;
557
+
558
+ /**
559
+ * Collect every page of `models.list/current` (Q10) into one catalog so the
560
+ * active-provider filter never drops models that only appear on later pages.
561
+ * The SDK pages Q10 at a fixed byte target (256 KiB), which a fully
562
+ * configured catalog can exceed. Returns the same
563
+ * `{ result: { page: { items } } }` envelope `pageItems` consumes.
564
+ */
565
+ export async function collectModelCatalog(adapter: Pick<AcpSdkAdapter, "query">): Promise<unknown> {
566
+ const items: unknown[] = [];
567
+ let cursor: string | undefined;
568
+ for (let pageCount = 0; pageCount < MAX_MODEL_CATALOG_PAGES; pageCount++) {
569
+ const response = await adapter.query("models.list/current", {}, cursor);
570
+ const page = queryPage(response);
571
+ if (!page) throw new AcpSdkAdapterError("protocol_error", "models.list/current returned no page.");
572
+ if (!Array.isArray(page.items))
573
+ throw new AcpSdkAdapterError("protocol_error", "models.list/current returned a malformed page.");
574
+ items.push(...page.items);
575
+ if (page.complete === true) return { result: { page: { items } } };
576
+ if (typeof page.continuationCursor !== "string")
577
+ throw new AcpSdkAdapterError(
578
+ "protocol_error",
579
+ "models.list/current page is incomplete without a continuation cursor.",
580
+ );
581
+ cursor = page.continuationCursor;
582
+ }
583
+ throw new AcpSdkAdapterError("protocol_error", "models.list/current exceeded the page budget.");
584
+ }
480
585
 
481
586
  const THINKING_CONFIG_OPTIONS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"].map(value => ({
482
587
  value,
@@ -484,7 +589,12 @@ const THINKING_CONFIG_OPTIONS = ["off", "minimal", "low", "medium", "high", "xhi
484
589
  }));
485
590
 
486
591
  /** Maps live canonical SDK config and the selected model catalog into the ACP 1.2.1 session state surface. */
487
- export function acpSessionStateFromConfig(query: unknown, modelCatalogQuery?: unknown, modelPreset?: string) {
592
+ export function acpSessionStateFromConfig(
593
+ query: unknown,
594
+ modelCatalogQuery?: unknown,
595
+ modelPreset?: string,
596
+ activeProviders?: ReadonlySet<string>,
597
+ ) {
488
598
  const values = configValues(query);
489
599
  const useModelPresets = modelPreset !== undefined;
490
600
  const currentModeId = values.get(MODE_CONFIG_ID) === ACP_PLAN_MODE_ID ? ACP_PLAN_MODE_ID : ACP_DEFAULT_MODE_ID;
@@ -493,6 +603,7 @@ export function acpSessionStateFromConfig(query: unknown, modelCatalogQuery?: un
493
603
  {
494
604
  id: MODE_CONFIG_ID,
495
605
  name: "Mode",
606
+ category: "mode" as const,
496
607
  type: "select" as const,
497
608
  currentValue: currentModeId,
498
609
  options: [
@@ -510,7 +621,7 @@ export function acpSessionStateFromConfig(query: unknown, modelCatalogQuery?: un
510
621
  option.id === MODEL_CONFIG_ID
511
622
  ? useModelPresets
512
623
  ? modelPresetConfigOptions(modelCatalogQuery, value)
513
- : modelConfigOptions(modelCatalogQuery, value)
624
+ : modelConfigOptions(modelCatalogQuery, value, activeProviders)
514
625
  : option.id === THINKING_CONFIG_ID
515
626
  ? THINKING_CONFIG_OPTIONS
516
627
  : [...option.options];
@@ -1799,8 +1910,13 @@ export class AcpAgent implements Agent {
1799
1910
  const modelPreset = this.#startupOptions?.modelPreset;
1800
1911
  const [config, modelCatalog] = await Promise.all([
1801
1912
  record.adapter.query("config.list/get"),
1802
- record.adapter.query(modelPreset === undefined ? "models.list/current" : "models.profiles.list"),
1913
+ modelPreset === undefined ? collectModelCatalog(record.adapter) : record.adapter.query("models.profiles.list"),
1803
1914
  ]);
1915
+ // Resolve usable providers in parallel. Only an older session host that
1916
+ // rejects `providers.list/active` with `operation_not_session_owned`
1917
+ // falls back to the full catalog; operational failures fail closed so
1918
+ // the active-provider contract is not silently widened.
1919
+ const activeProviders = modelPreset === undefined ? await collectActiveProviderIds(record.adapter) : undefined;
1804
1920
  record.authFailure = undefined;
1805
1921
  if (modelPreset !== undefined) {
1806
1922
  const activePreset = configValues(config).get(MODEL_PRESET_CONFIG_KEY);
@@ -1815,7 +1931,7 @@ export class AcpAgent implements Agent {
1815
1931
  throw new AcpSdkAdapterError("authentication_failed", record.authFailure);
1816
1932
  }
1817
1933
  }
1818
- return acpSessionStateFromConfig(config, modelCatalog, modelPreset);
1934
+ return acpSessionStateFromConfig(config, modelCatalog, modelPreset, activeProviders);
1819
1935
  }
1820
1936
 
1821
1937
  async #publishAvailableCommands(id: string, adapter: AcpSdkAdapter): Promise<void> {
@@ -1721,9 +1721,13 @@ export class ModelSelectorComponent extends Container {
1721
1721
  : choice.role === null
1722
1722
  ? "temporary model"
1723
1723
  : GJC_MODEL_ASSIGNMENT_TARGETS[choice.role].name;
1724
+ // Show the highlighted reasoning level — never the model id (that mislabel
1725
+ // made "Reasoning for Default: gpt-5.6-luna" look like a level).
1726
+ const selectedLevel = choice.levels[this.#selectedThinkingIndex];
1727
+ const selectedLevelLabel = selectedLevel ? getThinkingLevelMetadata(selectedLevel).label : "—";
1724
1728
  this.#listContainer.addChild(new Spacer(1));
1725
1729
  this.#listContainer.addChild(
1726
- new Text(theme.fg("muted", ` Reasoning for ${targetLabel}: ${choice.item.model.id}`), 0, 0),
1730
+ new Text(theme.fg("muted", ` Reasoning for ${targetLabel}: ${selectedLevelLabel}`), 0, 0),
1727
1731
  );
1728
1732
  this.#listContainer.addChild(new Spacer(1));
1729
1733
  for (let i = 0; i < choice.levels.length; i++) {
@@ -2079,8 +2083,13 @@ export class ModelSelectorComponent extends Container {
2079
2083
  this.#updateList();
2080
2084
  }
2081
2085
  }
2082
- #getInitialThinkingChoiceIndex(item: ModelItem | CanonicalModelItem, levels: ThinkingLevel[]): number {
2083
- const preferred = this.#getPreferredThinkingLevel(item);
2086
+ #getInitialThinkingChoiceIndex(
2087
+ item: ModelItem | CanonicalModelItem,
2088
+ levels: ThinkingLevel[],
2089
+ role: GjcModelAssignmentTargetId | null = null,
2090
+ roles?: readonly GjcModelAssignmentTargetId[],
2091
+ ): number {
2092
+ const preferred = this.#getPreferredThinkingLevel(item, role, roles);
2084
2093
  if (preferred && preferred !== ThinkingLevel.Inherit) {
2085
2094
  const index = levels.indexOf(preferred);
2086
2095
  if (index !== -1) return index;
@@ -2088,13 +2097,51 @@ export class ModelSelectorComponent extends Container {
2088
2097
  return 0;
2089
2098
  }
2090
2099
 
2091
- #getPreferredThinkingLevel(item: ModelItem | CanonicalModelItem): ThinkingLevel | undefined {
2100
+ /**
2101
+ * Prefer the role binding the user is editing when it already points at this
2102
+ * model so the reasoning menu opens on the level role badges already show
2103
+ * (e.g. EXECUTOR (xhigh) must not leave the cursor on "off").
2104
+ */
2105
+ #getPreferredThinkingLevel(
2106
+ item: ModelItem | CanonicalModelItem,
2107
+ role: GjcModelAssignmentTargetId | null = null,
2108
+ roles?: readonly GjcModelAssignmentTargetId[],
2109
+ ): ThinkingLevel | undefined {
2110
+ const roleThinking = this.#getAssignedThinkingLevelForModel(item.model, role, roles);
2111
+ if (roleThinking && roleThinking !== ThinkingLevel.Inherit) {
2112
+ return roleThinking;
2113
+ }
2092
2114
  if (item.thinkingLevel && item.thinkingLevel !== ThinkingLevel.Inherit) {
2093
2115
  return item.thinkingLevel;
2094
2116
  }
2095
2117
  return undefined;
2096
2118
  }
2097
2119
 
2120
+ #getAssignedThinkingLevelForModel(
2121
+ model: Model,
2122
+ role: GjcModelAssignmentTargetId | null,
2123
+ roles?: readonly GjcModelAssignmentTargetId[],
2124
+ ): ThinkingLevel | undefined {
2125
+ if (roles && roles.length > 0) {
2126
+ const assignedLevels = roles
2127
+ .map(targetRole => this.#roles[targetRole])
2128
+ .filter(
2129
+ (assigned): assigned is RoleAssignment =>
2130
+ assigned !== undefined &&
2131
+ modelsAreEqual(assigned.model, model) &&
2132
+ assigned.thinkingLevel !== ThinkingLevel.Inherit,
2133
+ )
2134
+ .map(assigned => assigned.thinkingLevel);
2135
+ if (assignedLevels.length === 0) return undefined;
2136
+ const first = assignedLevels[0];
2137
+ return assignedLevels.every(level => level === first) ? first : undefined;
2138
+ }
2139
+ if (role === null) return undefined;
2140
+ const assigned = this.#roles[role];
2141
+ if (!assigned || !modelsAreEqual(assigned.model, model)) return undefined;
2142
+ return assigned.thinkingLevel;
2143
+ }
2144
+
2098
2145
  #handleSelect(
2099
2146
  item: ModelItem | CanonicalModelItem,
2100
2147
  role: GjcModelAssignmentTargetId | null,
@@ -2109,7 +2156,7 @@ export class ModelSelectorComponent extends Container {
2109
2156
  if (!hasExplicitThinkingChoice && needsExplicitThinkingChoice) {
2110
2157
  const levels = getSelectableThinkingLevels(item.model);
2111
2158
  this.#pendingThinkingChoice = { item, role, roles, levels };
2112
- this.#selectedThinkingIndex = this.#getInitialThinkingChoiceIndex(item, levels);
2159
+ this.#selectedThinkingIndex = this.#getInitialThinkingChoiceIndex(item, levels, role, roles);
2113
2160
  this.#updateList();
2114
2161
  return;
2115
2162
  }
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { type AssistantMessage, type ImageContent, isContextOverflow } from "@gajae-code/ai";
9
9
  import { isKnownSinkPeerClosedError, logger, sanitizeText } from "@gajae-code/utils";
10
+ import { loadSlashCommands } from "../extensibility/slash-commands";
10
11
  import type { AgentSession } from "../session/agent-session";
11
12
  import { isSilentAbort } from "../session/messages";
12
13
  import { initializeExtensions } from "./runtime-init";
@@ -207,6 +208,15 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
207
208
  if (header) stdout.write(`${JSON.stringify(header)}\n`);
208
209
  }
209
210
 
211
+ // Bundled and file-based slash commands (`/init`, project `commands/*.md`, …) expand
212
+ // inside AgentSession#prompt from the list handed over by setSlashCommands. Interactive
213
+ // mode loads it while building its autocomplete; print mode has no autocomplete, so
214
+ // without this the list stays empty and a leading-slash prompt reaches the model as
215
+ // literal prose — no expansion, no error, and an answer that looks like the command ran.
216
+ await logger.time("print:slash-commands", async () => {
217
+ session.setSlashCommands(await loadSlashCommands({ cwd: session.sessionManager.getCwd() }));
218
+ });
219
+
210
220
  // Set up extensions for print mode (no UI, no command context).
211
221
  await initializeExtensions(session, {
212
222
  reportSendError: (action, err) => {
@@ -21,6 +21,25 @@ MUST read before making changes within:
21
21
  {{#list agentsMdSearch.files join="\n"}}- {{this}}{{/list}}
22
22
  </dir-context>
23
23
  {{/if}}
24
+
25
+ {{#if alwaysApplyRules.length}}
26
+ {{#each alwaysApplyRules}}
27
+ {{content}}
28
+ {{/each}}
29
+ {{/if}}
30
+ {{#if rules.length}}
31
+ Rules are local constraints. You MUST read `rule://<name>` when working in that domain.
32
+ <rules>
33
+ {{#list rules join="\n"}}
34
+ <rule name="{{name}}">
35
+ {{description}}
36
+ {{#if globs.length}}
37
+ {{#list globs join="\n"}}<glob>{{this}}</glob>{{/list}}
38
+ {{/if}}
39
+ </rule>
40
+ {{/list}}
41
+ </rules>
42
+ {{/if}}
24
43
  <critical>
25
44
  - Each response MUST advance the task. There is no stopping condition other than completion.
26
45
  - You MUST default to informed action; do not ask for confirmation when tools or repo context can answer.
@@ -427,6 +427,23 @@ export function finalizeTelegramHtml(message?: string): string | undefined {
427
427
  return truncateTelegramHtml(message);
428
428
  }
429
429
 
430
+ /** Multi-select state marker a transport may prepend to an option label. */
431
+ export const SELECTION_MARK_CHECKED = "☑";
432
+ export const SELECTION_MARK_UNCHECKED = "☐";
433
+ const SELECTION_MARK_PREFIX = new RegExp(`^\\s*([${SELECTION_MARK_CHECKED}${SELECTION_MARK_UNCHECKED}])\\s+`);
434
+
435
+ /**
436
+ * Drop a leading `N.`/`N)` index already embedded in an option label (e.g.
437
+ * deep-interview options pre-numbered by the ask tool) so the canonical
438
+ * one-based index can be applied instead. A selection marker stays in front of
439
+ * the text: the label is renumbered, not un-marked.
440
+ */
441
+ export function renumberableOptionText(label: string): string {
442
+ const marker = SELECTION_MARK_PREFIX.exec(label);
443
+ const rest = marker ? label.slice(marker[0].length) : label;
444
+ return `${marker ? `${marker[1]} ` : ""}${rest.replace(/^\s*\d+[.)]\s+/, "")}`;
445
+ }
446
+
430
447
  /**
431
448
  * One-based, plain-text button label (Telegram does not parse HTML in labels).
432
449
  *
@@ -436,13 +453,12 @@ export function finalizeTelegramHtml(message?: string): string | undefined {
436
453
  * `1. 1. …` and keeps the displayed number aligned with the button's real index.
437
454
  */
438
455
  export function buttonLabel(label: string, index: number): string {
439
- const stripped = label.replace(/^\s*\d+[.)]\s+/, "");
440
- return `${index + 1}. ${stripped}`;
456
+ return `${index + 1}. ${renumberableOptionText(label)}`;
441
457
  }
442
458
 
443
459
  /** Numbered, escaped option list for the Telegram message body. */
444
460
  export function numberedOptionList(labels: string[]): string {
445
- return labels.map((label, i) => `${i + 1}. ${escapeHtml(label.replace(/^\s*\d+[.)]\s+/, ""))}`).join("\n");
461
+ return labels.map((label, i) => `${i + 1}. ${escapeHtml(renumberableOptionText(label))}`).join("\n");
446
462
  }
447
463
 
448
464
  /** Compact numeric button label; full option text belongs in the message body. */
@@ -837,16 +837,22 @@ export class PresentationArbiter {
837
837
  ...(presentation.recommendedIndex === undefined
838
838
  ? {}
839
839
  : { recommendedIndex: presentation.recommendedIndex }),
840
- controls: presentation.multi
841
- ? [
842
- {
843
- id: "navigation_forward",
844
- kind: "navigation",
845
- label: presentation.navigationLabel ?? "Done",
846
- enabled: presentation.allowEmpty || presentation.selectedOptions.length > 0,
847
- },
848
- ]
849
- : presentation.controls,
840
+ // A presentation that carries its own controls (the ask tool's
841
+ // Next/Done) keeps them; a durable workflow gate presents none,
842
+ // so its navigation control is synthesized here.
843
+ controls:
844
+ presentation.controls.length > 0
845
+ ? presentation.controls
846
+ : presentation.multi
847
+ ? [
848
+ {
849
+ id: "navigation_forward",
850
+ kind: "navigation",
851
+ label: presentation.navigationLabel ?? "Done",
852
+ enabled: presentation.allowEmpty || presentation.selectedOptions.length > 0,
853
+ },
854
+ ]
855
+ : [],
850
856
  },
851
857
  { redact: this.redact(), sessionTag: this.tag },
852
858
  ),
@@ -1824,9 +1830,12 @@ function registerInteractiveAnswerSource(
1824
1830
  options: request.options,
1825
1831
  controls: request.controls,
1826
1832
  recommendedIndex: request.recommendedIndex,
1827
- multi: false,
1833
+ // The ask tool owns the multi-select loop and re-issues one request per
1834
+ // toggle; carrying its selection here is what makes the toggle visible
1835
+ // on a remote transport instead of an identical repeated prompt.
1836
+ multi: request.multi === true,
1828
1837
  allowEmpty: false,
1829
- selectedOptions: [],
1838
+ selectedOptions: [...(request.selectedOptions ?? [])],
1830
1839
  onActivated: (actionId, lease) => {
1831
1840
  if (pending.actionId && pendingInteractive.get(pending.actionId) === pending)
1832
1841
  pendingInteractive.delete(pending.actionId);
@@ -75,9 +75,13 @@ export const NOTIFICATION_PROTOCOL_VERSION = 3;
75
75
  * final-component file symlinks fail-closed under AT_SYMLINK_NOFOLLOW (bounded
76
76
  * #3761 multi-account activation repair). Generation 51 adds shared durable
77
77
  * topic authority, archive recovery, and requires Telegram's documented error
78
- * code for idempotent archive settlement.
78
+ * code for idempotent archive settlement. Generation 52 is claimed by the
79
+ * pre-readiness daemon-child exit diagnostics slice (#3761). Generation 53
80
+ * renders multi-select state for ask-tool asks, not only durable workflow
81
+ * gates, and renumbers pre-numbered options exactly once around the selection
82
+ * marker.
79
83
  */
80
- export const DAEMON_GENERATION = 51;
84
+ export const DAEMON_GENERATION = 53;
81
85
 
82
86
  /**
83
87
  * Serving-compatibility boundary for daemon lifecycle requests. Epoch 5
@@ -48,6 +48,8 @@ import {
48
48
  buildCompactChoiceGrid,
49
49
  code,
50
50
  markdownToTelegramHtml,
51
+ SELECTION_MARK_CHECKED,
52
+ SELECTION_MARK_UNCHECKED,
51
53
  splitTelegramHtml,
52
54
  TELEGRAM_MESSAGE_LIMIT,
53
55
  TELEGRAM_PARSE_MODE,
@@ -383,6 +385,10 @@ const BTW_QUESTION_LIMIT_TEXT = "Question must be at most 4096 Unicode scalar va
383
385
  type ParsedBtwCommand = { kind: "question"; question: string } | { kind: "ignored" };
384
386
  type TelegramFileDownload = { bytes: Buffer } | { failure: "download_failed" | "too_large" };
385
387
  class ThreadedModeCapabilityRefusal extends Error {}
388
+ /** Telegram answered `createForumTopic` with an explicit `ok: false` rejection. */
389
+ class TopicCreationRejected extends Error {}
390
+ /** The transport suppressed `createForumTopic` (no response body to classify). */
391
+ class TopicCreationSuppressed extends Error {}
386
392
 
387
393
  async function prepareTelegramImageAttachment(frame: Record<string, unknown>): Promise<Record<string, unknown>> {
388
394
  if (frame.type !== "image_attachment") return frame;
@@ -422,7 +428,7 @@ function isThreadedModeCapabilityRefusal(response: unknown): boolean {
422
428
  return (
423
429
  ok === false &&
424
430
  typeof description === "string" &&
425
- /(?:threaded mode|forum topics? (?:is|are) (?:disabled|not enabled)|(?:not allowed|not permitted|cannot|can't) create forum topics?)/i.test(
431
+ /(?:threaded mode|forum topics? (?:is|are) (?:disabled|not enabled)|(?:not allowed|not permitted|cannot|can't) create forum topics?|chat is not a forum)/i.test(
426
432
  description,
427
433
  )
428
434
  );
@@ -5068,6 +5074,8 @@ export class TelegramNotificationDaemon {
5068
5074
  private readonly flatIdentitySent = new Set<string>();
5069
5075
  /** Cached delivery boundary for the private owner chat or validation forum. */
5070
5076
  private pairedChatPrivacy: PairedChatPrivacy | undefined;
5077
+ /** Latched once Telegram confirms this chat cannot host forum topics. */
5078
+ private topicCapabilityRefused = false;
5071
5079
  /** Bot username from getMe, cached once at owner startup for group/forum command targeting. */
5072
5080
  private botUsername: string | undefined;
5073
5081
  /** Sessions whose agent loop is currently busy (drives the typing indicator). */
@@ -7836,8 +7844,6 @@ export class TelegramNotificationDaemon {
7836
7844
  let acceptedTopicId: string | undefined;
7837
7845
  let acceptedTopicCompensated = false;
7838
7846
  let acceptedTopicArchiveAttempted = false;
7839
- let creationSuppressed = false;
7840
- let creationRejected = false;
7841
7847
  let adoptedTopicId: number | undefined;
7842
7848
  const adoptionIntentCandidate = this.#adoptionIntents.bySession(sessionId);
7843
7849
  try {
@@ -7876,10 +7882,10 @@ export class TelegramNotificationDaemon {
7876
7882
  const res = (await this.botApi.call("createForumTopic", { chat_id: this.opts.chatId, name })) as
7877
7883
  | { result?: { message_thread_id?: unknown } }
7878
7884
  | undefined;
7879
- if (res === undefined) {
7880
- creationSuppressed = true;
7881
- return undefined;
7882
- }
7885
+ // Classification must travel with the rejection, not through
7886
+ // closure flags: `getOrCreateTopic` shares one in-flight create
7887
+ // promise, so every other awaiter observes only the error.
7888
+ if (res === undefined) throw new TopicCreationSuppressed();
7883
7889
  if (isThreadedModeCapabilityRefusal(res)) throw new ThreadedModeCapabilityRefusal();
7884
7890
  const response = res as {
7885
7891
  ok?: unknown;
@@ -7887,8 +7893,8 @@ export class TelegramNotificationDaemon {
7887
7893
  };
7888
7894
  const tid = response.result?.message_thread_id;
7889
7895
  if (typeof tid !== "number" || !Number.isSafeInteger(tid) || tid <= 0) {
7890
- creationRejected = response.ok === false;
7891
- if (!creationRejected) this.#malformedTopicCreateEndpoints.set(sessionId, creationEndpointKey);
7896
+ if (response.ok === false) throw new TopicCreationRejected();
7897
+ this.#malformedTopicCreateEndpoints.set(sessionId, creationEndpointKey);
7892
7898
  throw new Error("createForumTopic: invalid message_thread_id");
7893
7899
  }
7894
7900
  acceptedTopicId = String(tid);
@@ -8009,7 +8015,12 @@ export class TelegramNotificationDaemon {
8009
8015
  } catch (err) {
8010
8016
  if (adoptedTopicId !== undefined) this.#adoptionIntents.releaseClaim(adoptedTopicId, sessionId);
8011
8017
  if (adoptionIntentCandidate) this.topics.abandonCreateClaim(sessionId, creationLeaseEpoch);
8012
- if (creationSuppressed || creationRejected || err instanceof ThreadedModeCapabilityRefusal) {
8018
+ if (
8019
+ err instanceof TopicCreationSuppressed ||
8020
+ err instanceof TopicCreationRejected ||
8021
+ err instanceof ThreadedModeCapabilityRefusal
8022
+ ) {
8023
+ if (err instanceof ThreadedModeCapabilityRefusal) this.topicCapabilityRefused = true;
8013
8024
  if (this.topics.abandonCreateClaim(sessionId, creationLeaseEpoch)) await this.persistTopics();
8014
8025
  return undefined;
8015
8026
  }
@@ -9515,6 +9526,7 @@ export class TelegramNotificationDaemon {
9515
9526
  }
9516
9527
 
9517
9528
  private async pairedChatAllowsTopics(): Promise<boolean> {
9529
+ if (this.topicCapabilityRefused) return false;
9518
9530
  const privacy = await this.resolvePairedChatPrivacy();
9519
9531
  return privacy === "private" || privacy === "validation-forum";
9520
9532
  }
@@ -9556,7 +9568,11 @@ export class TelegramNotificationDaemon {
9556
9568
  private startFlushTimer(): void {
9557
9569
  this.runtime.startInterval("telegram-flush", RATE_LIMIT_FLUSH_INTERVAL_MS, () => {
9558
9570
  if (!this.running || this.pool.pending === 0) return;
9559
- void this.flushPool();
9571
+ // `flushPool` only swallows failures for the shared chain; the promise it
9572
+ // returns still rejects, and an unhandled rejection here exits the daemon.
9573
+ void this.flushPool().catch(error => {
9574
+ logger.warn(`notifications: queue flush failed: ${sanitizeDiagnostic(String(error), this.opts.botToken)}`);
9575
+ });
9560
9576
  });
9561
9577
  }
9562
9578
 
@@ -9596,11 +9612,27 @@ export class TelegramNotificationDaemon {
9596
9612
  this.runtime.stopInterval("telegram-owner-heartbeat");
9597
9613
  }
9598
9614
 
9599
- /** Run a root scan, guarding against overlapping scans from the timer + loop. */
9615
+ /**
9616
+ * Run a root scan, guarding against overlapping scans from the timer + loop.
9617
+ *
9618
+ * A reconciliation pass is retried every scan interval, so a failed one must
9619
+ * never tear the owner down. Both callers are fatal boundaries: the timer
9620
+ * fires and forgets, and the run loop awaits without a handler. A rejection
9621
+ * escaping either one (a shared topic authority whose lock or compare-and-set
9622
+ * is momentarily unavailable is the observed case) reaches the process-level
9623
+ * handler, which exits the daemon: every session topic is then left as an
9624
+ * unarchived shell that answers nothing, and the sessions that were still
9625
+ * live lose notifications too. Report the failure and let the next scan
9626
+ * retry instead.
9627
+ */
9600
9628
  private async runScan(): Promise<void> {
9601
- await this.runtime.runExclusive("telegram-scan", async () => {
9602
- await this.scanRoots();
9603
- });
9629
+ try {
9630
+ await this.runtime.runExclusive("telegram-scan", async () => {
9631
+ await this.scanRoots();
9632
+ });
9633
+ } catch (error) {
9634
+ logger.warn(`notifications: session scan failed: ${sanitizeDiagnostic(String(error), this.opts.botToken)}`);
9635
+ }
9604
9636
  }
9605
9637
 
9606
9638
  private startScanTimer(): void {
@@ -10376,7 +10408,9 @@ export class TelegramNotificationDaemon {
10376
10408
  : [],
10377
10409
  );
10378
10410
  const displayOptions = options.map((option, index) =>
10379
- multiSelect ? `${selectedOptionIndices.has(index) ? "☑" : "☐"} ${option}` : option,
10411
+ multiSelect
10412
+ ? `${selectedOptionIndices.has(index) ? SELECTION_MARK_CHECKED : SELECTION_MARK_UNCHECKED} ${option}`
10413
+ : option,
10380
10414
  );
10381
10415
  const rendered = buildActionMessage({
10382
10416
  kind: msg.kind ?? "ask",