@gajae-code/coding-agent 0.9.1 → 0.9.2

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 (62) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/types/cli/args.d.ts +1 -0
  3. package/dist/types/cli/notify-cli.d.ts +5 -0
  4. package/dist/types/cli.d.ts +3 -0
  5. package/dist/types/commands/launch.d.ts +3 -0
  6. package/dist/types/config/model-profile-activation.d.ts +7 -7
  7. package/dist/types/config/model-registry.d.ts +7 -3
  8. package/dist/types/config/model-resolver.d.ts +2 -2
  9. package/dist/types/config/settings-schema.d.ts +1 -1
  10. package/dist/types/config/settings.d.ts +7 -0
  11. package/dist/types/extensibility/runtime-skill-discovery.d.ts +20 -0
  12. package/dist/types/gjc-runtime/team-runtime.d.ts +9 -0
  13. package/dist/types/modes/components/settings-selector.d.ts +2 -2
  14. package/dist/types/modes/components/thinking-selector.d.ts +6 -1
  15. package/dist/types/modes/types.d.ts +1 -0
  16. package/dist/types/notifications/config-commands.d.ts +29 -0
  17. package/dist/types/notifications/index.d.ts +12 -1
  18. package/dist/types/notifications/telegram-daemon-cli.d.ts +3 -3
  19. package/dist/types/notifications/threaded-render.d.ts +2 -0
  20. package/dist/types/notifications/topic-registry.d.ts +4 -5
  21. package/dist/types/runtime-credential-selector.d.ts +7 -0
  22. package/dist/types/sdk.d.ts +7 -1
  23. package/dist/types/session/agent-session.d.ts +6 -3
  24. package/dist/types/tools/index.d.ts +1 -0
  25. package/dist/types/tools/skill-discovery.d.ts +40 -0
  26. package/package.json +7 -7
  27. package/src/cli/args.ts +8 -0
  28. package/src/cli/notify-cli.ts +27 -1
  29. package/src/cli.ts +5 -0
  30. package/src/commands/launch.ts +5 -0
  31. package/src/commands/notify.ts +2 -2
  32. package/src/config/model-profile-activation.ts +23 -7
  33. package/src/config/model-registry.ts +21 -4
  34. package/src/config/model-resolver.ts +5 -16
  35. package/src/config/settings-schema.ts +3 -2
  36. package/src/config/settings.ts +24 -1
  37. package/src/extensibility/runtime-skill-discovery.ts +229 -0
  38. package/src/gjc-runtime/launch-tmux.ts +72 -12
  39. package/src/gjc-runtime/psmux-detect.ts +17 -2
  40. package/src/gjc-runtime/team-runtime.ts +35 -3
  41. package/src/main.ts +26 -3
  42. package/src/modes/components/settings-selector.ts +3 -3
  43. package/src/modes/components/thinking-selector.ts +79 -18
  44. package/src/modes/controllers/input-controller.ts +50 -9
  45. package/src/modes/controllers/selector-controller.ts +7 -5
  46. package/src/modes/types.ts +1 -0
  47. package/src/modes/utils/ui-helpers.ts +48 -15
  48. package/src/notifications/config-commands.ts +70 -0
  49. package/src/notifications/index.ts +156 -9
  50. package/src/notifications/rich-render.ts +1 -0
  51. package/src/notifications/telegram-daemon-cli.ts +13 -5
  52. package/src/notifications/telegram-daemon.ts +87 -4
  53. package/src/notifications/threaded-render.ts +14 -0
  54. package/src/notifications/topic-registry.ts +9 -8
  55. package/src/prompts/tools/skill-discovery.md +13 -0
  56. package/src/runtime-credential-selector.ts +35 -0
  57. package/src/sdk.ts +68 -5
  58. package/src/session/agent-session.ts +42 -18
  59. package/src/system-prompt.ts +4 -6
  60. package/src/tools/index.ts +4 -0
  61. package/src/tools/skill-discovery.ts +73 -0
  62. package/src/tools/skill.ts +15 -2
@@ -36,17 +36,27 @@ interface RenderInitialMessagesOptions {
36
36
  preserveExistingChat?: boolean;
37
37
  }
38
38
 
39
+ function cloneRenderArgs(args: Record<string, unknown>): Record<string, unknown> {
40
+ try {
41
+ return structuredClone(args);
42
+ } catch {
43
+ return { ...args };
44
+ }
45
+ }
46
+
39
47
  export function argsWithPartialJson(args: unknown, partialJson: unknown): unknown {
40
48
  if (typeof partialJson !== "string" || !args || typeof args !== "object" || Array.isArray(args)) return args;
41
- // Non-enumerable so the transient streaming buffer reaches renderers via direct
42
- // property read but never serializes into persisted/wire message content.
43
- Object.defineProperty(args, "__partialJson", {
49
+ // Keep the transient streaming buffer on a renderer-only snapshot. The live
50
+ // assistant message args are later validated/executed, so UI-only metadata or
51
+ // renderer mutations must never share that object reference.
52
+ const renderArgs = cloneRenderArgs(args as Record<string, unknown>);
53
+ Object.defineProperty(renderArgs, "__partialJson", {
44
54
  value: partialJson,
45
55
  enumerable: false,
46
56
  configurable: true,
47
57
  writable: true,
48
58
  });
49
- return args;
59
+ return renderArgs;
50
60
  }
51
61
 
52
62
  type QueuedMessages = {
@@ -619,7 +629,11 @@ export class UiHelpers {
619
629
  }
620
630
 
621
631
  queueCompactionMessage(text: string, mode: "steer" | "followUp"): void {
622
- this.ctx.compactionQueuedMessages.push({ text, mode } as CompactionQueuedMessage);
632
+ const entry: CompactionQueuedMessage = { text, mode };
633
+ if (mode === "followUp") {
634
+ entry.followUpQueuePolicy = "sequential";
635
+ }
636
+ this.ctx.compactionQueuedMessages.push(entry);
623
637
  this.ctx.editor.addToHistory(text);
624
638
  this.ctx.editor.setText("");
625
639
  this.ctx.updatePendingMessagesDisplay();
@@ -633,6 +647,11 @@ export class UiHelpers {
633
647
  return this.#hasSkillInvocations(text) || this.isKnownSlashCommand(text);
634
648
  }
635
649
 
650
+ #compactionFollowUpQueuePolicy(message: CompactionQueuedMessage): "sequential" | undefined {
651
+ if (message.mode !== "followUp") return undefined;
652
+ return message.followUpQueuePolicy ?? "sequential";
653
+ }
654
+
636
655
  async #deliverQueuedSkillMessage(message: CompactionQueuedMessage): Promise<boolean> {
637
656
  const invocations = parseSkillInvocations(message.text, this.ctx.skillCommands ?? new Map());
638
657
  if (invocations.length === 0) {
@@ -675,6 +694,13 @@ export class UiHelpers {
675
694
  continue;
676
695
  }
677
696
 
697
+ const promptOptions =
698
+ message.mode === "followUp"
699
+ ? {
700
+ streamingBehavior: message.mode,
701
+ followUpQueuePolicy: this.#compactionFollowUpQueuePolicy(message),
702
+ }
703
+ : { streamingBehavior: message.mode };
678
704
  await this.ctx.session.promptCustomMessage(
679
705
  {
680
706
  customType: SKILL_PROMPT_MESSAGE_TYPE,
@@ -683,7 +709,7 @@ export class UiHelpers {
683
709
  details,
684
710
  attribution: "user",
685
711
  },
686
- { streamingBehavior: message.mode },
712
+ promptOptions,
687
713
  );
688
714
  }
689
715
 
@@ -703,7 +729,11 @@ export class UiHelpers {
703
729
  return;
704
730
  }
705
731
  await this.ctx.withLocalSubmission(message.text, () =>
706
- message.mode === "followUp" ? this.ctx.session.followUp(message.text) : this.ctx.session.steer(message.text),
732
+ message.mode === "followUp"
733
+ ? this.ctx.session.followUp(message.text, undefined, {
734
+ followUpQueuePolicy: this.#compactionFollowUpQueuePolicy(message),
735
+ })
736
+ : this.ctx.session.steer(message.text),
707
737
  );
708
738
  }
709
739
 
@@ -794,14 +824,17 @@ export class UiHelpers {
794
824
  // `restoreQueue` rather than rethrown, so we use the primitive
795
825
  // recordLocalSubmission and dispose manually in the catch.
796
826
  const disposeFirstPrompt = this.ctx.recordLocalSubmission(firstPrompt.text);
797
- const promptPromise = this.ctx.session
798
- .prompt(firstPrompt.text, {
799
- streamingBehavior: firstPrompt.mode === "followUp" ? "followUp" : "steer",
800
- })
801
- .catch((error: unknown) => {
802
- disposeFirstPrompt();
803
- restoreQueue(error);
804
- });
827
+ const firstPromptOptions =
828
+ firstPrompt.mode === "followUp"
829
+ ? {
830
+ streamingBehavior: "followUp" as const,
831
+ followUpQueuePolicy: this.#compactionFollowUpQueuePolicy(firstPrompt),
832
+ }
833
+ : { streamingBehavior: "steer" as const };
834
+ const promptPromise = this.ctx.session.prompt(firstPrompt.text, firstPromptOptions).catch((error: unknown) => {
835
+ disposeFirstPrompt();
836
+ restoreQueue(error);
837
+ });
805
838
 
806
839
  for (const message of rest) {
807
840
  await this.#deliverQueuedMessage(message);
@@ -20,6 +20,76 @@ export interface ConfigCommandChange {
20
20
  redact?: boolean;
21
21
  }
22
22
 
23
+ export type TelegramControlCommandName = "reasoning" | "usage" | "context" | "compact";
24
+
25
+ export type TelegramControlCommand =
26
+ | { name: "reasoning"; action: "cycle" | "status" | "set"; level?: string }
27
+ | { name: "usage" }
28
+ | { name: "context" }
29
+ | { name: "compact"; instructions?: string };
30
+
31
+ export type TelegramControlCommandParseResult =
32
+ | { kind: "none" }
33
+ | { kind: "ignored"; commandName: TelegramControlCommandName }
34
+ | { kind: "command"; command: TelegramControlCommand }
35
+ | { kind: "invalid"; commandName: TelegramControlCommandName; usage: string };
36
+
37
+ const TELEGRAM_CONTROL_COMMANDS = new Set<TelegramControlCommandName>(["reasoning", "usage", "context", "compact"]);
38
+ const TELEGRAM_REASONING_LEVELS = new Set(["inherit", "off", "minimal", "low", "medium", "high", "xhigh", "max"]);
39
+
40
+ function splitTelegramBotSuffix(rawCommand: string): { name: string; suffix?: string } {
41
+ const [name, suffix] = rawCommand.toLowerCase().split("@", 2);
42
+ return suffix ? { name, suffix } : { name };
43
+ }
44
+
45
+ export function telegramControlCommandUsage(commandName: TelegramControlCommandName): string {
46
+ switch (commandName) {
47
+ case "reasoning":
48
+ return "Usage: /reasoning [cycle|inherit|off|minimal|low|medium|high|xhigh|max]";
49
+ case "usage":
50
+ return "Usage: /usage";
51
+ case "context":
52
+ return "Usage: /context";
53
+ case "compact":
54
+ return "Usage: /compact [instructions]";
55
+ }
56
+ }
57
+
58
+ /** Parse deterministic Telegram session-control commands. Recognised roots fail closed. */
59
+ export function parseTelegramControlCommand(text: string, botUsername?: string): TelegramControlCommandParseResult {
60
+ const trimmed = text.trim();
61
+ if (!trimmed.startsWith("/")) return { kind: "none" };
62
+ const [rawRoot, ...rest] = trimmed.slice(1).split(/\s+/);
63
+ if (!rawRoot) return { kind: "none" };
64
+ const { name: root, suffix } = splitTelegramBotSuffix(rawRoot);
65
+ if (!TELEGRAM_CONTROL_COMMANDS.has(root as TelegramControlCommandName)) return { kind: "none" };
66
+ const commandName = root as TelegramControlCommandName;
67
+ if (suffix && (!botUsername || suffix !== botUsername.toLowerCase())) return { kind: "ignored", commandName };
68
+ const usage = telegramControlCommandUsage(commandName);
69
+
70
+ switch (commandName) {
71
+ case "usage":
72
+ case "context":
73
+ return rest.length === 0
74
+ ? { kind: "command", command: { name: commandName } }
75
+ : { kind: "invalid", commandName, usage };
76
+ case "compact": {
77
+ const instructions = trimmed.slice(rawRoot.length + 1).trim();
78
+ return { kind: "command", command: instructions ? { name: "compact", instructions } : { name: "compact" } };
79
+ }
80
+ case "reasoning": {
81
+ if (rest.length === 0) return { kind: "command", command: { name: "reasoning", action: "status" } };
82
+ if (rest.length !== 1) return { kind: "invalid", commandName, usage };
83
+ const levelOrAction = rest[0]!.toLowerCase();
84
+ if (levelOrAction === "cycle") return { kind: "command", command: { name: "reasoning", action: "cycle" } };
85
+ if (TELEGRAM_REASONING_LEVELS.has(levelOrAction)) {
86
+ return { kind: "command", command: { name: "reasoning", action: "set", level: levelOrAction } };
87
+ }
88
+ return { kind: "invalid", commandName, usage };
89
+ }
90
+ }
91
+ }
92
+
23
93
  /**
24
94
  * Parse an in-thread config command. Returns the requested change, or
25
95
  * `undefined` when the text is not a recognised config command (so the daemon
@@ -24,11 +24,13 @@ import * as fs from "node:fs";
24
24
  import * as os from "node:os";
25
25
  import * as path from "node:path";
26
26
  import { promisify } from "node:util";
27
+ import { ThinkingLevel } from "@gajae-code/agent-core";
27
28
  import type { ImageContent, TextContent } from "@gajae-code/ai";
28
29
  import { NotificationServer } from "@gajae-code/natives";
29
30
  import { logger, postmortem } from "@gajae-code/utils";
30
31
  import { Settings } from "../config/settings";
31
32
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "../extensibility/extensions";
33
+ import { parseThinkingLevel } from "../thinking";
32
34
  import { registerAskAnswerSource } from "../tools/ask-answer-registry";
33
35
  import { registerTelegramFileSink } from "./attachment-registry";
34
36
  import {
@@ -407,20 +409,18 @@ function streamIntervalMs(): number {
407
409
  return Math.max(200, Number(process.env.GJC_NOTIFICATIONS_STREAM_INTERVAL_MS) || 500);
408
410
  }
409
411
  // Max chars of a turn's assistant text carried by the FINALIZED turn_stream (and
410
- // the pre-ask capture). Default 3500 keeps the mirror a glanceable per-turn
411
- // summary; a client that splits long messages (the Telegram daemon does so via
412
- // splitTelegramHtml, scheduling each chunk through the shared rate-limit pool so
413
- // the fan-out never bypasses the per-chat limit) can raise it with
414
- // GJC_NOTIFICATIONS_TURN_MAX to deliver full turns. The value is clamped to a
415
- // finite [280, TURN_TEXT_MAX_CEILING] range: a non-finite or non-positive env
416
- // (unset, NaN, Infinity, <= 0) falls back to the default, so the cap can never
417
- // be unbounded. Live frames are intentionally NOT raised — they stay one
412
+ // the pre-ask capture). Finalized turns default to the bounded full-turn ceiling
413
+ // because split-capable clients such as the Telegram daemon schedule each
414
+ // splitTelegramHtml chunk through the shared rate-limit pool. Operators who want
415
+ // glanceable summaries can lower this with GJC_NOTIFICATIONS_TURN_MAX. The value
416
+ // is always clamped to a finite [280, TURN_TEXT_MAX_CEILING] range so the cap can
417
+ // never be unbounded. Live frames are intentionally NOT raised — they stay one
418
418
  // editable preview message rather than fanning a long in-progress turn across
419
419
  // sends.
420
420
  const TURN_TEXT_MAX_CEILING = 40_000;
421
421
  function turnTextMax(): number {
422
422
  const raw = Number(process.env.GJC_NOTIFICATIONS_TURN_MAX);
423
- if (!Number.isFinite(raw) || raw <= 0) return 3500;
423
+ if (!Number.isFinite(raw) || raw <= 0) return TURN_TEXT_MAX_CEILING;
424
424
  return Math.min(TURN_TEXT_MAX_CEILING, Math.max(280, raw));
425
425
  }
426
426
  function resolveSettings(settingsOverride?: Settings): ResolvedSettings {
@@ -487,6 +487,121 @@ function mapAnswerToGate(
487
487
  return { selected: [] };
488
488
  }
489
489
 
490
+ interface NotificationControlCommandPayload {
491
+ name?: unknown;
492
+ action?: unknown;
493
+ level?: unknown;
494
+ instructions?: unknown;
495
+ }
496
+
497
+ function parseControlCommandPayload(json: string | undefined): NotificationControlCommandPayload | undefined {
498
+ if (!json) return undefined;
499
+ try {
500
+ const parsed = JSON.parse(json) as unknown;
501
+ return parsed && typeof parsed === "object" ? (parsed as NotificationControlCommandPayload) : undefined;
502
+ } catch {
503
+ return undefined;
504
+ }
505
+ }
506
+
507
+ function formatCompactTokenCount(value: number | null | undefined): string {
508
+ if (value == null) return "unknown";
509
+ if (value >= 1_000_000) return `${Number((value / 1_000_000).toFixed(value % 1_000_000 === 0 ? 0 : 1))}m`;
510
+ if (value >= 1_000) return `${Number((value / 1_000).toFixed(value % 1_000 === 0 ? 0 : 1))}k`;
511
+ return value.toLocaleString();
512
+ }
513
+
514
+ function formatContextUsageLine(ctx: ExtensionContext): string {
515
+ const usage = ctx.getContextUsage();
516
+ if (!usage) return "Context usage unavailable.";
517
+ const tokens = formatCompactTokenCount(usage.tokens);
518
+ const window = formatCompactTokenCount(usage.contextWindow);
519
+ const pct = usage.percent == null ? "unknown" : `${usage.percent.toFixed(1)}%`;
520
+ return `Context: ${tokens}/${window} ${pct}`;
521
+ }
522
+
523
+ function formatLocalUsage(ctx: ExtensionContext): string {
524
+ const stats = ctx.sessionManager.getUsageStatistics();
525
+ return [
526
+ "Usage",
527
+ `Input tokens: ${stats.input}`,
528
+ `Output tokens: ${stats.output}`,
529
+ `Cache read tokens: ${stats.cacheRead}`,
530
+ `Cache write tokens: ${stats.cacheWrite}`,
531
+ `Premium requests: ${stats.premiumRequests}`,
532
+ `Cost: $${stats.cost.toFixed(6)}`,
533
+ ].join("\n");
534
+ }
535
+
536
+ function cycleTelegramThinking(api: ExtensionAPI): ThinkingLevel | undefined {
537
+ const levels = [
538
+ ThinkingLevel.Off,
539
+ ThinkingLevel.Minimal,
540
+ ThinkingLevel.Low,
541
+ ThinkingLevel.Medium,
542
+ ThinkingLevel.High,
543
+ ThinkingLevel.XHigh,
544
+ ThinkingLevel.Max,
545
+ ];
546
+ const current = api.getThinkingLevel() ?? ThinkingLevel.Off;
547
+ const currentIndex = levels.indexOf(current as (typeof levels)[number]);
548
+ const next = levels[(currentIndex + 1) % levels.length];
549
+ if (!next) return undefined;
550
+ api.setThinkingLevel(next);
551
+ return api.getThinkingLevel() ?? next;
552
+ }
553
+
554
+ export async function executeNotificationControlCommand(
555
+ command: NotificationControlCommandPayload | undefined,
556
+ ctx: ExtensionContext,
557
+ api: ExtensionAPI,
558
+ ): Promise<{ status: "ok" | "error" | "unavailable"; message: string }> {
559
+ if (!command || typeof command.name !== "string") return { status: "error", message: "Invalid control command." };
560
+ switch (command.name) {
561
+ case "reasoning": {
562
+ const current = api.getThinkingLevel() ?? ThinkingLevel.Off;
563
+ if (command.action === "status") return { status: "ok", message: `Reasoning effort: ${current}` };
564
+ if (command.action === "cycle") {
565
+ const next = cycleTelegramThinking(api);
566
+ return next
567
+ ? { status: "ok", message: `Reasoning effort set to ${next}.` }
568
+ : { status: "unavailable", message: "Reasoning effort unavailable for this session." };
569
+ }
570
+ if (command.action === "set" && typeof command.level === "string") {
571
+ const parsed = parseThinkingLevel(command.level);
572
+ if (!parsed) return { status: "error", message: "Invalid reasoning effort." };
573
+ api.setThinkingLevel(parsed);
574
+ return { status: "ok", message: `Reasoning effort set to ${api.getThinkingLevel() ?? ThinkingLevel.Off}.` };
575
+ }
576
+ return { status: "error", message: "Invalid reasoning command." };
577
+ }
578
+ case "usage":
579
+ return { status: "ok", message: formatLocalUsage(ctx) };
580
+ case "context":
581
+ return { status: "ok", message: formatContextUsageLine(ctx) };
582
+ case "compact": {
583
+ const before = ctx.getContextUsage()?.tokens;
584
+ try {
585
+ await ctx.compact(typeof command.instructions === "string" ? command.instructions : undefined);
586
+ } catch (err) {
587
+ return {
588
+ status: "error",
589
+ message: `Compaction failed: ${err instanceof Error ? err.message : String(err)}`,
590
+ };
591
+ }
592
+ const after = ctx.getContextUsage()?.tokens;
593
+ if (before != null && after != null)
594
+ return {
595
+ status: "ok",
596
+ message: `Compaction complete. Tokens: ${before} -> ${after} (saved ${before - after}).`,
597
+ };
598
+ return { status: "ok", message: "Compaction complete." };
599
+ }
600
+ default:
601
+ return { status: "error", message: "Unknown control command." };
602
+ }
603
+ }
604
+
490
605
  /** Register the interactive `ask` answer source for a session (the ask tool
491
606
  * races the local UI against a remote reply). Returns the deregister disposer. */
492
607
  function registerInteractiveAnswerSource(
@@ -703,6 +818,38 @@ export function createNotificationsExtension(api: ExtensionAPI, options: { setti
703
818
  }
704
819
  }
705
820
  }
821
+ if (inbound.kind === "control_command") {
822
+ if (!runtime || !inbound.requestId) return;
823
+ void executeNotificationControlCommand(parseControlCommandPayload(inbound.commandJson), ctx, api)
824
+ .then(result => {
825
+ runtime?.server.pushFrame(
826
+ JSON.stringify({
827
+ type: "control_command_result",
828
+ sessionId: id,
829
+ requestId: inbound.requestId,
830
+ updateId: inbound.updateId,
831
+ status: result.status,
832
+ message: result.message,
833
+ }),
834
+ );
835
+ })
836
+ .catch(err => {
837
+ try {
838
+ runtime?.server.pushFrame(
839
+ JSON.stringify({
840
+ type: "control_command_result",
841
+ sessionId: id,
842
+ requestId: inbound.requestId,
843
+ updateId: inbound.updateId,
844
+ status: "error",
845
+ message: `Control command failed: ${err instanceof Error ? err.message : String(err)}`,
846
+ }),
847
+ );
848
+ } catch (pushErr) {
849
+ logger.warn(`notifications: control_command_result failed: ${String(pushErr)}`);
850
+ }
851
+ });
852
+ }
706
853
  });
707
854
 
708
855
  try {
@@ -49,6 +49,7 @@ export function shouldPromoteRich(input: { enabled?: boolean; send: ThreadedSend
49
49
  send.editable !== true &&
50
50
  typeof send.richMarkdown === "string" &&
51
51
  send.richMarkdown.trim().length > 0 &&
52
+ send.richMarkdown.length <= RICH_MESSAGE_LIMIT &&
52
53
  typeof send.text === "string" &&
53
54
  send.text.length > 0
54
55
  );
@@ -15,7 +15,9 @@ type TelegramDaemonRunner = {
15
15
  type TelegramDaemonConstructor = new (opts: TelegramDaemonOptions) => TelegramDaemonRunner;
16
16
 
17
17
  export interface RunDaemonInternalDeps {
18
- SettingsImpl?: { init: (options?: { agentDir?: string }) => Promise<Pick<Settings, "get" | "getAgentDir" | "set">> };
18
+ SettingsImpl?: {
19
+ init: (options?: { agentDir?: string }) => Promise<Pick<Settings, "get" | "getAgentDir" | "set" | "flush">>;
20
+ };
19
21
  DaemonImpl?: TelegramDaemonConstructor;
20
22
  processPid?: number;
21
23
  pidAlive?: (pid: number) => boolean;
@@ -61,7 +63,7 @@ function asIdleTimeoutMs(value: unknown): number {
61
63
  export function createLightweightDaemonSettings(input: {
62
64
  agentDir: string;
63
65
  rawConfig?: unknown;
64
- }): Pick<Settings, "get" | "getAgentDir" | "set"> {
66
+ }): Pick<Settings, "get" | "getAgentDir" | "set" | "flush"> {
65
67
  const rawConfig = input.rawConfig && typeof input.rawConfig === "object" ? input.rawConfig : {};
66
68
  return {
67
69
  get(pathName: string): unknown {
@@ -120,12 +122,18 @@ export function createLightweightDaemonSettings(input: {
120
122
  });
121
123
  setByPath(rawConfig as Record<string, unknown>, segments, value);
122
124
  },
123
- } as Pick<Settings, "get" | "getAgentDir" | "set">;
125
+ async flush(): Promise<void> {
126
+ // The set() above is synchronously durable (it awaits the atomic tmp+rename
127
+ // write under the shared file lock), so there is never a pending save to
128
+ // flush. Present so the daemon can await flush() uniformly regardless of
129
+ // which Settings implementation is injected.
130
+ },
131
+ } as Pick<Settings, "get" | "getAgentDir" | "set" | "flush">;
124
132
  }
125
133
 
126
134
  export async function loadLightweightDaemonSettings(
127
135
  agentDir: string,
128
- ): Promise<Pick<Settings, "get" | "getAgentDir" | "set">> {
136
+ ): Promise<Pick<Settings, "get" | "getAgentDir" | "set" | "flush">> {
129
137
  const configPath = path.join(agentDir, "config.yml");
130
138
  let rawConfig: unknown = {};
131
139
  try {
@@ -139,7 +147,7 @@ export async function loadLightweightDaemonSettings(
139
147
  async function resolveDaemonSettings(
140
148
  agentDir: string,
141
149
  deps: RunDaemonInternalDeps,
142
- ): Promise<Pick<Settings, "get" | "getAgentDir" | "set">> {
150
+ ): Promise<Pick<Settings, "get" | "getAgentDir" | "set" | "flush">> {
143
151
  if (deps.SettingsImpl) return await deps.SettingsImpl.init({ agentDir });
144
152
  return await loadLightweightDaemonSettings(agentDir);
145
153
  }
@@ -9,7 +9,7 @@ import type { Settings } from "../config/settings";
9
9
  import type { DaemonRuntimeInfo } from "../daemon/control-types";
10
10
  import { resolveGjcRuntimeSpawnInfo } from "../daemon/runtime";
11
11
  import { getNotificationConfig, isTelegramConfigured, tokenFingerprint } from "./config";
12
- import { parseInThreadConfigCommand, parseRichToggleCommand } from "./config-commands";
12
+ import { parseInThreadConfigCommand, parseRichToggleCommand, parseTelegramControlCommand } from "./config-commands";
13
13
  import { daemonPaths } from "./daemon-paths";
14
14
  import {
15
15
  buildCompactChoiceGrid,
@@ -111,6 +111,21 @@ export const CLIENT_PING_PONG_CAPABILITY = "client_ping_pong";
111
111
  export const NOTIFICATION_PROTOCOL_VERSION = 2;
112
112
 
113
113
  const nodeFs: TelegramDaemonFs = fs.promises as unknown as TelegramDaemonFs;
114
+
115
+ /**
116
+ * Durably persist a `/rich` toggle. A real {@link Settings} exposes
117
+ * `flushOrThrow()`, which rejects on a failed config.yml write (its `set()` is a
118
+ * fire-and-forget whose background save swallows errors). The lightweight daemon
119
+ * settings has no `flushOrThrow` — its `set()` already wrote durably and throws
120
+ * on failure — so its plain `flush()` no-op drain is sufficient.
121
+ */
122
+ async function flushRichToggleSettings(settings: Settings): Promise<void> {
123
+ if (typeof settings.flushOrThrow === "function") {
124
+ await settings.flushOrThrow();
125
+ return;
126
+ }
127
+ await settings.flush();
128
+ }
114
129
  const RATE_LIMIT_FLUSH_INTERVAL_MS = 1_000;
115
130
  // How often the daemon rescans for newly-started sessions. This MUST run
116
131
  // independently of the Telegram getUpdates long-poll (up to 25s): otherwise a
@@ -1550,6 +1565,7 @@ export class TelegramNotificationDaemon {
1550
1565
  "image_attachment",
1551
1566
  "file_attachment",
1552
1567
  "config_update",
1568
+ "control_command_result",
1553
1569
  ]);
1554
1570
 
1555
1571
  private topicNameFor(sessionId: string, msg: { title?: unknown; repo?: unknown; branch?: unknown }): string {
@@ -1913,7 +1929,7 @@ export class TelegramNotificationDaemon {
1913
1929
  // upstream edit/send path, so off behavior is byte-identical.
1914
1930
  if (
1915
1931
  shouldPromoteRich({
1916
- enabled: this.opts.rich?.enabled === false ? false : true,
1932
+ enabled: this.opts.rich?.enabled !== false,
1917
1933
  send,
1918
1934
  })
1919
1935
  ) {
@@ -2219,16 +2235,22 @@ export class TelegramNotificationDaemon {
2219
2235
  // Rename the topic if the title changed (e.g. the session title was
2220
2236
  // auto-generated after the topic was first created). This runs on
2221
2237
  // every identity frame, but does NOT re-send the bulleted message.
2238
+ // Only commit the new registry name after Telegram accepts the edit:
2239
+ // a transient editForumTopic failure must remain retryable on the
2240
+ // next identity re-assert instead of leaving the remote topic stuck
2241
+ // at the provisional "GJC <id>" name forever.
2222
2242
  const name = this.topicNameFor(session.sessionId, msg);
2223
- if (this.topics.applyName(session.sessionId, name)) {
2243
+ if (this.topics.needsRename(session.sessionId, name)) {
2224
2244
  try {
2225
2245
  await this.botApi.call("editForumTopic", {
2226
2246
  chat_id: this.opts.chatId,
2227
2247
  message_thread_id: Number(topicId),
2228
2248
  name,
2229
2249
  });
2250
+ this.topics.markNameApplied(session.sessionId, name);
2230
2251
  } catch {
2231
- // Best-effort rename; never block delivery.
2252
+ // Best-effort rename; never block delivery. Leave the old
2253
+ // registry name intact so a later identity frame retries.
2232
2254
  }
2233
2255
  }
2234
2256
  // Send the full bulleted identity header EXACTLY ONCE per topic.
@@ -2415,6 +2437,15 @@ export class TelegramNotificationDaemon {
2415
2437
  }
2416
2438
  try {
2417
2439
  await this.opts.settings.set("notifications.telegram.rich.enabled", desired);
2440
+ // Confirm success only after a DURABLE write. The real Settings.set is
2441
+ // a synchronous fire-and-forget whose queued save (Settings.#saveNow)
2442
+ // swallows write errors, and Settings.flush() inherits that — neither
2443
+ // rejects on a failed config.yml write. flushOrThrow() rethrows the
2444
+ // durable-write failure so it lands in the catch below (in-memory
2445
+ // isolated Settings short-circuit and never throw). The lightweight
2446
+ // daemon settings has no flushOrThrow: its set() already wrote durably
2447
+ // (and throws on failure), so its flush() is only a no-op drain.
2448
+ await flushRichToggleSettings(this.opts.settings);
2418
2449
  } catch (err) {
2419
2450
  logger.warn(
2420
2451
  `notifications: /rich settings write failed (${err instanceof Error ? err.message : String(err)}); runtime unchanged`,
@@ -2468,6 +2499,45 @@ export class TelegramNotificationDaemon {
2468
2499
  const injectedText = repliedOriginal
2469
2500
  ? `> replied-to message:\n${repliedOriginal}\n\n${baseInjectedText}`
2470
2501
  : baseInjectedText;
2502
+ const control = hasMedia
2503
+ ? { kind: "none" as const }
2504
+ : parseTelegramControlCommand(inbound.text, this.botUsername);
2505
+ if (control.kind !== "none") {
2506
+ await this.rememberSeenUpdateId(inbound.updateId);
2507
+ const sendControlNotice = async (body: string): Promise<void> => {
2508
+ try {
2509
+ await this.botApi.call("sendMessage", {
2510
+ chat_id: this.opts.chatId,
2511
+ message_thread_id: Number(inbound.threadId),
2512
+ text: body,
2513
+ parse_mode: TELEGRAM_PARSE_MODE,
2514
+ });
2515
+ } catch {
2516
+ // Best-effort control feedback; never convert to user input.
2517
+ }
2518
+ };
2519
+ if (control.kind === "ignored") return;
2520
+ if (control.kind === "invalid") {
2521
+ await sendControlNotice(control.usage);
2522
+ return;
2523
+ }
2524
+ if (session?.ws.readyState !== WebSocket.OPEN) {
2525
+ await sendControlNotice("Session control unavailable: session is disconnected.");
2526
+ return;
2527
+ }
2528
+ session.ws.send(
2529
+ JSON.stringify({
2530
+ type: "control_command",
2531
+ sessionId: inbound.sessionId,
2532
+ token: session.token,
2533
+ requestId: `tg:${inbound.updateId}`,
2534
+ updateId: inbound.updateId,
2535
+ threadId: inbound.threadId,
2536
+ command: control.command,
2537
+ }),
2538
+ );
2539
+ return;
2540
+ }
2471
2541
  const cfg = hasMedia ? undefined : parseInThreadConfigCommand(inbound.text);
2472
2542
  // A plain (non-config) message while an ask is pending for this session
2473
2543
  // answers that ask as free-input — instead of starting a new user turn.
@@ -2484,6 +2554,15 @@ export class TelegramNotificationDaemon {
2484
2554
  }),
2485
2555
  );
2486
2556
  await this.rememberSeenUpdateId(inbound.updateId);
2557
+ await this.botApi
2558
+ .call("sendMessage", {
2559
+ chat_id: this.opts.chatId,
2560
+ message_thread_id: Number(inbound.threadId),
2561
+ text: "Received as an answer to the pending ask.",
2562
+ })
2563
+ .catch(error => {
2564
+ logger.warn(`telegram: failed to acknowledge pending ask reply: ${String(error)}`);
2565
+ });
2487
2566
  if (inbound.messageId !== undefined) await this.setReaction(inbound.messageId, QUEUED_REACTION);
2488
2567
  return;
2489
2568
  }
@@ -2548,6 +2627,10 @@ export class TelegramNotificationDaemon {
2548
2627
  { command: "lean", description: "Mirror assistant text + tool names only (default)" },
2549
2628
  { command: "redact", description: "Toggle redaction of streamed content: /redact <on|off>" },
2550
2629
  { command: "rich", description: "Toggle rich Telegram delivery: /rich <on|off>" },
2630
+ { command: "reasoning", description: "Show or change reasoning effort in this session" },
2631
+ { command: "usage", description: "Show provider/local usage for this session" },
2632
+ { command: "context", description: "Show current context usage for this session" },
2633
+ { command: "compact", description: "Compact this session: /compact [instructions]" },
2551
2634
  { command: "session_create", description: "Create a GJC session: path, worktree, or dir [--mpreset]" },
2552
2635
  { command: "session_recent", description: "List recent GJC sessions" },
2553
2636
  { command: "session_close", description: "Close a GJC-managed session" },
@@ -77,6 +77,9 @@ interface ThreadedFrame {
77
77
  // config_update
78
78
  verbosity?: unknown;
79
79
  redact?: unknown;
80
+ // control_command_result
81
+ status?: unknown;
82
+ message?: unknown;
80
83
  }
81
84
 
82
85
  function str(v: unknown): string | undefined {
@@ -218,6 +221,17 @@ export function renderThreadedFrame(frame: ThreadedFrame): ThreadedSend | undefi
218
221
  }
219
222
  : undefined;
220
223
  }
224
+ case "control_command_result": {
225
+ const message = str(frame.message);
226
+ if (!message) return undefined;
227
+ const status = str(frame.status);
228
+ const prefix = status === "ok" ? "✅" : status === "unavailable" ? "⚠️" : "❌";
229
+ return {
230
+ method: "sendMessage",
231
+ lane: "idle",
232
+ text: finalizeTelegramHtml(`${prefix} ${escapeHtml(truncate(message, 1200))}`),
233
+ };
234
+ }
221
235
  default:
222
236
  return undefined;
223
237
  }
@@ -120,15 +120,16 @@ export class TopicRegistry {
120
120
  return record ? !record.identitySent : true;
121
121
  }
122
122
 
123
- /**
124
- * Record the topic's applied title. Returns `true` when it changed (so the
125
- * caller should `editForumTopic`), `false` when already current or unknown.
126
- */
127
- applyName(sessionId: string, name: string): boolean {
123
+ /** Whether a known session topic's applied title differs from `name`. */
124
+ needsRename(sessionId: string, name: string): boolean {
128
125
  const record = this.topics.get(sessionId);
129
- if (!record || record.name === name) return false;
130
- record.name = name;
131
- return true;
126
+ return record !== undefined && record.name !== name;
127
+ }
128
+
129
+ /** Commit a successfully-applied Telegram topic title. */
130
+ markNameApplied(sessionId: string, name: string): void {
131
+ const record = this.topics.get(sessionId);
132
+ if (record) record.name = name;
132
133
  }
133
134
 
134
135
  /** Remove a session topic record after Telegram deletes the topic. */