@gajae-code/coding-agent 0.7.3 → 0.7.5

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 (118) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/bin/gjc.js +4 -0
  3. package/dist/types/cli/plugin-cli.d.ts +2 -0
  4. package/dist/types/commands/plugin.d.ts +6 -0
  5. package/dist/types/commands/session.d.ts +6 -0
  6. package/dist/types/config/model-profile-activation.d.ts +8 -1
  7. package/dist/types/extensibility/gjc-plugins/compiler.d.ts +19 -0
  8. package/dist/types/extensibility/gjc-plugins/constrained-hooks.d.ts +29 -0
  9. package/dist/types/extensibility/gjc-plugins/index.d.ts +9 -0
  10. package/dist/types/extensibility/gjc-plugins/injection.d.ts +9 -0
  11. package/dist/types/extensibility/gjc-plugins/installer.d.ts +13 -0
  12. package/dist/types/extensibility/gjc-plugins/mcp-policy.d.ts +26 -0
  13. package/dist/types/extensibility/gjc-plugins/observability.d.ts +27 -0
  14. package/dist/types/extensibility/gjc-plugins/prompt-appendix.d.ts +16 -0
  15. package/dist/types/extensibility/gjc-plugins/registry.d.ts +32 -0
  16. package/dist/types/extensibility/gjc-plugins/runtime-adapters.d.ts +64 -0
  17. package/dist/types/extensibility/gjc-plugins/session-validation.d.ts +42 -0
  18. package/dist/types/extensibility/gjc-plugins/types.d.ts +158 -2
  19. package/dist/types/extensibility/gjc-plugins/validation.d.ts +8 -1
  20. package/dist/types/gjc-runtime/launch-tmux.d.ts +1 -0
  21. package/dist/types/gjc-runtime/psmux-detect.d.ts +78 -0
  22. package/dist/types/gjc-runtime/team-runtime.d.ts +2 -0
  23. package/dist/types/gjc-runtime/tmux-common.d.ts +30 -2
  24. package/dist/types/gjc-runtime/tmux-sessions.d.ts +18 -0
  25. package/dist/types/main.d.ts +2 -0
  26. package/dist/types/modes/components/model-selector.d.ts +6 -0
  27. package/dist/types/notifications/html-format.d.ts +11 -0
  28. package/dist/types/notifications/index.d.ts +149 -1
  29. package/dist/types/notifications/lifecycle-commands.d.ts +72 -0
  30. package/dist/types/notifications/lifecycle-control-runtime.d.ts +98 -0
  31. package/dist/types/notifications/lifecycle-orchestrator.d.ts +144 -0
  32. package/dist/types/notifications/rate-limit-pool.d.ts +2 -0
  33. package/dist/types/notifications/recent-activity.d.ts +35 -0
  34. package/dist/types/notifications/telegram-daemon.d.ts +60 -0
  35. package/dist/types/notifications/telegram-reference.d.ts +3 -1
  36. package/dist/types/notifications/topic-registry.d.ts +10 -9
  37. package/dist/types/runtime-mcp/types.d.ts +7 -0
  38. package/dist/types/sdk.d.ts +2 -0
  39. package/dist/types/session/agent-session.d.ts +14 -4
  40. package/dist/types/session/blob-store.d.ts +25 -0
  41. package/dist/types/session/session-manager.d.ts +57 -0
  42. package/dist/types/slash-commands/helpers/fast-status-report.d.ts +6 -0
  43. package/dist/types/system-prompt.d.ts +2 -0
  44. package/dist/types/task/executor.d.ts +9 -1
  45. package/dist/types/tools/index.d.ts +3 -1
  46. package/dist/types/utils/changelog.d.ts +1 -0
  47. package/package.json +11 -9
  48. package/scripts/g004-tmux-smoke.ts +100 -0
  49. package/scripts/g005-daemon-smoke.ts +181 -0
  50. package/scripts/g011-daemon-path-smoke.ts +153 -0
  51. package/src/cli/plugin-cli.ts +66 -3
  52. package/src/cli.ts +21 -4
  53. package/src/commands/plugin.ts +4 -0
  54. package/src/commands/session.ts +18 -0
  55. package/src/config/model-profile-activation.ts +55 -7
  56. package/src/defaults/gjc/extensions/grok-cli-vendor/biome.json +1 -1
  57. package/src/defaults/gjc/skills/deep-interview/SKILL.md +3 -3
  58. package/src/defaults/gjc/skills/team/SKILL.md +5 -4
  59. package/src/defaults/gjc/skills/ultragoal/SKILL.md +41 -13
  60. package/src/export/html/index.ts +2 -2
  61. package/src/extensibility/gjc-plugins/compiler.ts +351 -0
  62. package/src/extensibility/gjc-plugins/constrained-hooks.ts +170 -0
  63. package/src/extensibility/gjc-plugins/index.ts +9 -0
  64. package/src/extensibility/gjc-plugins/injection.ts +109 -0
  65. package/src/extensibility/gjc-plugins/installer.ts +434 -0
  66. package/src/extensibility/gjc-plugins/loader.ts +3 -1
  67. package/src/extensibility/gjc-plugins/mcp-policy.ts +239 -0
  68. package/src/extensibility/gjc-plugins/observability.ts +84 -0
  69. package/src/extensibility/gjc-plugins/paths.ts +1 -1
  70. package/src/extensibility/gjc-plugins/prompt-appendix.ts +109 -0
  71. package/src/extensibility/gjc-plugins/registry.ts +180 -0
  72. package/src/extensibility/gjc-plugins/runtime-adapters.ts +234 -0
  73. package/src/extensibility/gjc-plugins/schema.ts +250 -20
  74. package/src/extensibility/gjc-plugins/session-validation.ts +147 -0
  75. package/src/extensibility/gjc-plugins/types.ts +199 -3
  76. package/src/extensibility/gjc-plugins/validation.ts +80 -0
  77. package/src/extensibility/skills.ts +15 -0
  78. package/src/gjc-runtime/launch-tmux.ts +58 -15
  79. package/src/gjc-runtime/psmux-detect.ts +239 -0
  80. package/src/gjc-runtime/team-runtime.ts +56 -23
  81. package/src/gjc-runtime/tmux-common.ts +85 -3
  82. package/src/gjc-runtime/tmux-sessions.ts +111 -9
  83. package/src/gjc-runtime/ultragoal-runtime.ts +75 -15
  84. package/src/internal-urls/docs-index.generated.ts +5 -4
  85. package/src/main.ts +14 -3
  86. package/src/modes/components/assistant-message.ts +49 -1
  87. package/src/modes/components/hook-editor.ts +1 -1
  88. package/src/modes/components/hook-selector.ts +67 -43
  89. package/src/modes/components/model-selector.ts +44 -11
  90. package/src/modes/controllers/extension-ui-controller.ts +0 -27
  91. package/src/modes/controllers/selector-controller.ts +50 -11
  92. package/src/modes/interactive-mode.ts +2 -0
  93. package/src/modes/utils/hotkeys-markdown.ts +1 -1
  94. package/src/notifications/html-format.ts +38 -0
  95. package/src/notifications/index.ts +242 -12
  96. package/src/notifications/lifecycle-commands.ts +228 -0
  97. package/src/notifications/lifecycle-control-runtime.ts +400 -0
  98. package/src/notifications/lifecycle-orchestrator.ts +358 -0
  99. package/src/notifications/rate-limit-pool.ts +19 -0
  100. package/src/notifications/recent-activity.ts +132 -0
  101. package/src/notifications/telegram-daemon.ts +433 -8
  102. package/src/notifications/telegram-reference.ts +25 -7
  103. package/src/notifications/topic-registry.ts +18 -9
  104. package/src/prompts/agents/executor.md +2 -2
  105. package/src/runtime-mcp/transports/stdio.ts +38 -4
  106. package/src/runtime-mcp/types.ts +7 -0
  107. package/src/sdk.ts +157 -10
  108. package/src/session/agent-session.ts +166 -74
  109. package/src/session/blob-store.ts +196 -8
  110. package/src/session/session-manager.ts +739 -12
  111. package/src/slash-commands/builtin-registry.ts +23 -3
  112. package/src/slash-commands/helpers/fast-status-report.ts +13 -3
  113. package/src/system-prompt.ts +9 -0
  114. package/src/task/executor.ts +31 -7
  115. package/src/task/index.ts +2 -0
  116. package/src/tools/ask.ts +5 -1
  117. package/src/tools/index.ts +3 -1
  118. package/src/utils/changelog.ts +8 -0
@@ -10,9 +10,32 @@ import type { DaemonRuntimeInfo } from "../daemon/control-types";
10
10
  import { resolveGjcRuntimeSpawnInfo } from "../daemon/runtime";
11
11
  import { getNotificationConfig, isGloballyConfigured, tokenFingerprint } from "./config";
12
12
  import { parseInThreadConfigCommand } from "./config-commands";
13
- import { buildButtonGrid, TELEGRAM_PARSE_MODE } from "./html-format";
13
+ import { buildCompactChoiceGrid, TELEGRAM_PARSE_MODE } from "./html-format";
14
+ import type {
15
+ SessionCloseTarget,
16
+ SessionCreateTarget,
17
+ SessionLifecycleRequest,
18
+ SessionLifecycleResponse,
19
+ SessionResumeTarget,
20
+ } from "./index";
21
+ import {
22
+ formatLifecycleOutcome,
23
+ isLifecycleCommandText,
24
+ lifecycleUsage,
25
+ parseLifecycleCommand,
26
+ validateLifecycleTarget,
27
+ } from "./lifecycle-commands";
28
+ import {
29
+ attachLifecycleControl,
30
+ buildOrchestratorDeps,
31
+ type ControlServerLike,
32
+ createNativeControlServer,
33
+ type LifecycleControlServer,
34
+ type LifecycleControlServerFactory,
35
+ } from "./lifecycle-control-runtime";
14
36
  import { NotificationOperatorRuntime, OperatorBackoffPolicy, OperatorEventRouter } from "./operator-runtime";
15
37
  import { RateLimitPool } from "./rate-limit-pool";
38
+ import { listRecentSessions } from "./recent-activity";
16
39
  import {
17
40
  type AliasTable,
18
41
  buildActionMessage,
@@ -173,6 +196,31 @@ export function daemonPaths(agentDir: string): DaemonPaths {
173
196
  };
174
197
  }
175
198
 
199
+ /**
200
+ * Attach session-lifecycle control (create/close/resume) to the running daemon.
201
+ *
202
+ * Wires an already-started, authenticated control server to the lifecycle
203
+ * orchestrator with real daemon-side effects (tmux launcher / force-close /
204
+ * resume), a durable fsynced idempotency ledger + audit JSONL under the agent
205
+ * notifications dir, and strict paired-chat gating. The control server itself
206
+ * (NotificationControlServer) is owned/started by the daemon process; this
207
+ * function only connects it to policy. Returns the orchestrator deps for tests.
208
+ */
209
+ export function startDaemonLifecycleControl(input: {
210
+ controlServer: ControlServerLike;
211
+ pairedChatId: string;
212
+ agentDir: string;
213
+ env?: NodeJS.ProcessEnv;
214
+ }): void {
215
+ const deps = buildOrchestratorDeps({
216
+ pairedChatId: input.pairedChatId,
217
+ agentNotificationsDir: daemonPaths(input.agentDir).dir,
218
+ sessionsRoot: path.join(input.agentDir, "sessions"),
219
+ env: input.env,
220
+ });
221
+ attachLifecycleControl(input.controlServer, deps);
222
+ }
223
+
176
224
  async function ensureDir(fsImpl: TelegramDaemonFs, dir: string): Promise<void> {
177
225
  await fsImpl.mkdir(dir, { recursive: true, mode: 0o700 });
178
226
  await fsImpl.chmod(dir, 0o700).catch(() => undefined);
@@ -706,8 +754,17 @@ export interface TelegramDaemonOptions {
706
754
  idleTimeoutMs?: number;
707
755
  scanIntervalMs?: number;
708
756
  pid?: number;
757
+ /** Liveness probe for skipping dead-PID endpoint records in {@link TelegramNotificationDaemon.scanRoots}. */
758
+ pidAlive?: (pid: number) => boolean;
709
759
  botApi?: BotApi;
710
760
  control?: DaemonControlHooks;
761
+ /**
762
+ * Factory for the session-lifecycle control server. Defaults to the real
763
+ * native NotificationControlServer; tests inject a fake to verify the
764
+ * owner-bound start/stop lifecycle without a socket. When `undefined` AND no
765
+ * default applies (e.g. lifecycle control disabled), no control server starts.
766
+ */
767
+ createLifecycleControlServer?: LifecycleControlServerFactory | null;
711
768
  }
712
769
 
713
770
  interface SessionSocket {
@@ -763,6 +820,25 @@ export class TelegramNotificationDaemon {
763
820
  private get inboundReactions(): Map<number, { messageId: number }> {
764
821
  return this.dispatchState.inboundReactions;
765
822
  }
823
+ /**
824
+ * The owner-bound session-lifecycle control server (create/close/resume).
825
+ * Started in {@link run} after ownership is confirmed (so exactly one owner
826
+ * ever runs one), stopped in run()'s finally on any exit path.
827
+ */
828
+ private controlServer: LifecycleControlServer | undefined;
829
+ /** True while lifecycle control is active, so the loop keeps polling at idle. */
830
+ private lifecycleControlActive = false;
831
+ /** Control token (in-memory) the loopback client presents; never persisted/logged. */
832
+ private controlToken: string | undefined;
833
+ /** Loopback WS client to the daemon's own control endpoint (Option A real wire path). */
834
+ private controlClient: WebSocket | undefined;
835
+ /** Pending lifecycle responses awaiting a control-endpoint reply, by requestId. */
836
+ private readonly pendingLifecycle = new Map<
837
+ string,
838
+ { resolve: (r: SessionLifecycleResponse) => void; timer: ReturnType<typeof setTimeout> }
839
+ >();
840
+ /** Monotonic counter for unique lifecycle request ids. */
841
+ private lifecycleSeq = 0;
766
842
 
767
843
  /**
768
844
  * Cooperatively stop the daemon: set the stop flag and abort the in-flight
@@ -774,6 +850,282 @@ export class TelegramNotificationDaemon {
774
850
  this.running = false;
775
851
  }
776
852
 
853
+ /**
854
+ * Start the owner-bound lifecycle control server and wire it to the
855
+ * orchestrator. Called from {@link run} ONLY after ownership is confirmed, so
856
+ * exactly one owner ever starts exactly one control server (no second poller
857
+ * / 409). A control-server failure degrades gracefully: the daemon keeps
858
+ * serving notifications without lifecycle control. Returns true when started.
859
+ */
860
+ private async startLifecycleControl(): Promise<boolean> {
861
+ const factory =
862
+ this.opts.createLifecycleControlServer === null
863
+ ? undefined
864
+ : (this.opts.createLifecycleControlServer ?? createNativeControlServer);
865
+ if (!factory) return false;
866
+ let server: LifecycleControlServer | undefined;
867
+ try {
868
+ // High-entropy, in-memory control token (never persisted raw / logged).
869
+ const token = crypto.randomBytes(32).toString("base64url");
870
+ const agentDir = this.opts.settings.getAgentDir();
871
+ server = factory({ token, ownerId: this.opts.ownerId, agentDir });
872
+ const deps = buildOrchestratorDeps({
873
+ pairedChatId: this.opts.chatId,
874
+ agentNotificationsDir: daemonPaths(agentDir).dir,
875
+ sessionsRoot: path.join(agentDir, "sessions"),
876
+ });
877
+ // Register the lifecycle-request handler BEFORE start(): the native
878
+ // control server captures the callback at start time, so wiring must
879
+ // precede start or forwarded requests never reach the orchestrator.
880
+ attachLifecycleControl(server, deps);
881
+ const endpoint = (await server.start()) as { url?: string } | undefined;
882
+ this.controlServer = server;
883
+ this.controlToken = token;
884
+ // Option A: connect a loopback WS client to our own control endpoint so
885
+ // parsed /session_* commands traverse the real authenticated wire path.
886
+ // Mark control active ONLY after the client is open, so a first-poll
887
+ // /session_create never races a still-CONNECTING socket.
888
+ const opened = endpoint?.url ? await this.connectControlClient(endpoint.url, token) : false;
889
+ this.lifecycleControlActive = opened;
890
+ if (!opened) {
891
+ logger.warn("notifications: lifecycle control client did not open; lifecycle commands disabled");
892
+ }
893
+ return opened;
894
+ } catch (e) {
895
+ // Never let lifecycle-control startup kill the notifications daemon.
896
+ // Stop any partially-started server so it cannot leak.
897
+ try {
898
+ server?.stop();
899
+ } catch {
900
+ // best-effort
901
+ }
902
+ logger.warn(`notifications: lifecycle control failed to start: ${String(e)}`);
903
+ this.controlServer = undefined;
904
+ this.lifecycleControlActive = false;
905
+ return false;
906
+ }
907
+ }
908
+
909
+ /** Stop the lifecycle control server (idempotent); called from run()'s finally. */
910
+ private stopLifecycleControl(): void {
911
+ this.lifecycleControlActive = false;
912
+ this.controlToken = undefined;
913
+ const client = this.controlClient;
914
+ this.controlClient = undefined;
915
+ try {
916
+ client?.close();
917
+ } catch {
918
+ // best-effort
919
+ }
920
+ // Reject any in-flight lifecycle requests so callers do not hang.
921
+ for (const [requestId, pending] of this.pendingLifecycle) {
922
+ clearTimeout(pending.timer);
923
+ pending.resolve({
924
+ type: "session_lifecycle_error",
925
+ requestId,
926
+ status: "error",
927
+ reason: "terminal_uncertain",
928
+ message: "control server stopped",
929
+ });
930
+ }
931
+ this.pendingLifecycle.clear();
932
+ const server = this.controlServer;
933
+ this.controlServer = undefined;
934
+ try {
935
+ server?.stop();
936
+ } catch (e) {
937
+ logger.warn(`notifications: lifecycle control failed to stop cleanly: ${String(e)}`);
938
+ }
939
+ }
940
+
941
+ /**
942
+ * Connect the loopback control client and resolve responses by requestId.
943
+ * Resolves true once the socket is OPEN (bounded), false on error/timeout, so
944
+ * the caller only marks lifecycle control active when commands can be sent.
945
+ */
946
+ private connectControlClient(url: string, token: string): Promise<boolean> {
947
+ return new Promise<boolean>(resolve => {
948
+ let settled = false;
949
+ const finish = (ok: boolean) => {
950
+ if (settled) return;
951
+ settled = true;
952
+ resolve(ok);
953
+ };
954
+ try {
955
+ const WsCtor = this.opts.WebSocketImpl ?? WebSocket;
956
+ const client = new WsCtor(`${url}/?token=${encodeURIComponent(token)}`);
957
+ this.controlClient = client;
958
+ const openTimer = (this.opts.setTimeoutImpl ?? setTimeout)(() => finish(false), 5_000);
959
+ client.addEventListener("open", () => {
960
+ clearTimeout(openTimer);
961
+ finish(true);
962
+ });
963
+ client.addEventListener("error", () => {
964
+ clearTimeout(openTimer);
965
+ finish(false);
966
+ });
967
+ client.addEventListener("message", (ev: MessageEvent) => {
968
+ let msg: SessionLifecycleResponse;
969
+ try {
970
+ msg = JSON.parse(String((ev as { data: unknown }).data)) as SessionLifecycleResponse;
971
+ } catch {
972
+ return;
973
+ }
974
+ const requestId = (msg as { requestId?: string }).requestId;
975
+ if (!requestId) return;
976
+ const pending = this.pendingLifecycle.get(requestId);
977
+ if (!pending) return;
978
+ clearTimeout(pending.timer);
979
+ this.pendingLifecycle.delete(requestId);
980
+ pending.resolve(msg);
981
+ });
982
+ } catch (e) {
983
+ logger.warn(`notifications: lifecycle control client failed to connect: ${String(e)}`);
984
+ finish(false);
985
+ }
986
+ });
987
+ }
988
+
989
+ /** Send a lifecycle frame over the loopback client and await the response. */
990
+ private submitLifecycleFrame(frame: SessionLifecycleRequest): Promise<SessionLifecycleResponse> {
991
+ return new Promise<SessionLifecycleResponse>(resolve => {
992
+ const client = this.controlClient;
993
+ if (!client || client.readyState !== WebSocket.OPEN) {
994
+ resolve({
995
+ type: "session_lifecycle_error",
996
+ requestId: frame.requestId,
997
+ status: "error",
998
+ reason: "terminal_uncertain",
999
+ message: "lifecycle control unavailable",
1000
+ });
1001
+ return;
1002
+ }
1003
+ const timer = (this.opts.setTimeoutImpl ?? setTimeout)(() => {
1004
+ this.pendingLifecycle.delete(frame.requestId);
1005
+ resolve({
1006
+ type: "session_lifecycle_error",
1007
+ requestId: frame.requestId,
1008
+ status: "error",
1009
+ reason: "readiness_timeout",
1010
+ message: "lifecycle request timed out",
1011
+ });
1012
+ }, 120_000);
1013
+ this.pendingLifecycle.set(frame.requestId, { resolve, timer });
1014
+ try {
1015
+ client.send(JSON.stringify(frame));
1016
+ } catch (e) {
1017
+ clearTimeout(timer);
1018
+ this.pendingLifecycle.delete(frame.requestId);
1019
+ resolve({
1020
+ type: "session_lifecycle_error",
1021
+ requestId: frame.requestId,
1022
+ status: "error",
1023
+ reason: "terminal_uncertain",
1024
+ message: `lifecycle send failed: ${String(e)}`,
1025
+ });
1026
+ }
1027
+ });
1028
+ }
1029
+
1030
+ private nextLifecycleRequestId(): string {
1031
+ this.lifecycleSeq += 1;
1032
+ return `tg-${this.opts.ownerId}-${this.lifecycleSeq}-${crypto.randomBytes(4).toString("hex")}`;
1033
+ }
1034
+
1035
+ /** Build an authenticated lifecycle frame from a parsed command + identity. */
1036
+ private buildLifecycleFrame(
1037
+ parsed:
1038
+ | { kind: "create"; target: SessionCreateTarget }
1039
+ | { kind: "close"; target: SessionCloseTarget }
1040
+ | { kind: "resume"; target: SessionResumeTarget },
1041
+ updateId: number,
1042
+ ): SessionLifecycleRequest {
1043
+ const requestId = this.nextLifecycleRequestId();
1044
+ const token = this.controlToken ?? "";
1045
+ const chatId = this.opts.chatId;
1046
+ if (parsed.kind === "create") {
1047
+ return {
1048
+ type: "session_create",
1049
+ requestId,
1050
+ lifecycleRequestId: requestId,
1051
+ intendedSessionId: `s${crypto.randomBytes(6).toString("hex")}`,
1052
+ updateId,
1053
+ chatId,
1054
+ token,
1055
+ target: parsed.target,
1056
+ };
1057
+ }
1058
+ if (parsed.kind === "close") {
1059
+ return { type: "session_close", requestId, updateId, chatId, token, target: parsed.target, force: true };
1060
+ }
1061
+ return { type: "session_resume", requestId, updateId, chatId, token, target: parsed.target };
1062
+ }
1063
+
1064
+ /**
1065
+ * Handle a paired-chat /session_* command: validate (shared validator),
1066
+ * route to the control endpoint, and reply with the outcome. Returns true
1067
+ * when the message was a lifecycle command (so the caller stops processing).
1068
+ */
1069
+ private async handleLifecycleCommand(
1070
+ text: string | undefined,
1071
+ updateId: number | undefined,
1072
+ threadId: number | undefined,
1073
+ ): Promise<boolean> {
1074
+ if (!isLifecycleCommandText(text)) return false;
1075
+ const reply = (body: string) =>
1076
+ this.botApi
1077
+ .call("sendMessage", {
1078
+ chat_id: this.opts.chatId,
1079
+ ...(threadId !== undefined ? { message_thread_id: threadId } : {}),
1080
+ text: body,
1081
+ })
1082
+ .catch(() => undefined);
1083
+
1084
+ if (!this.lifecycleControlActive) {
1085
+ await reply("Session lifecycle control is not available right now.");
1086
+ return true;
1087
+ }
1088
+ if (updateId !== undefined && this.dispatchState.seenUpdateIds.has(updateId)) return true;
1089
+ if (updateId !== undefined) this.dispatchState.seenUpdateIds.add(updateId);
1090
+
1091
+ const parsed = parseLifecycleCommand(text);
1092
+ if (parsed.kind === "none") return false;
1093
+ if (parsed.kind === "usage" || parsed.kind === "reject") {
1094
+ await reply(parsed.message);
1095
+ return true;
1096
+ }
1097
+ if (parsed.kind === "recent") {
1098
+ const recent = listRecentSessions({
1099
+ sessionsRoot: path.join(this.opts.settings.getAgentDir(), "sessions"),
1100
+ limit: 10,
1101
+ });
1102
+ const lines = recent.length
1103
+ ? recent.map(e => `\u2022 ${e.sessionId}${e.path ? ` (${e.path})` : ""}`).join("\n")
1104
+ : "No recent sessions.";
1105
+ await reply(lines);
1106
+ return true;
1107
+ }
1108
+
1109
+ // Defensive shared-validator pre-check before any effect.
1110
+ const verb =
1111
+ parsed.kind === "create" ? "session_create" : parsed.kind === "close" ? "session_close" : "session_resume";
1112
+ const valid = validateLifecycleTarget(verb, parsed.target);
1113
+ if (!valid.ok) {
1114
+ await reply(`${valid.message}\n\n${lifecycleUsage()}`);
1115
+ return true;
1116
+ }
1117
+
1118
+ const frame = this.buildLifecycleFrame(parsed, updateId ?? Date.now());
1119
+ const response = await this.submitLifecycleFrame(frame);
1120
+ await reply(this.formatLifecycleResponse(response));
1121
+ return true;
1122
+ }
1123
+
1124
+ /** Map a lifecycle response/error to a user-facing message (G010 surfacing). */
1125
+ private formatLifecycleResponse(r: SessionLifecycleResponse): string {
1126
+ return formatLifecycleOutcome(r);
1127
+ }
1128
+
777
1129
  constructor(private readonly opts: TelegramDaemonOptions) {
778
1130
  this.fsImpl = opts.fs ?? nodeFs;
779
1131
  this.aliasTable = createAliasTable();
@@ -847,6 +1199,14 @@ export class TelegramNotificationDaemon {
847
1199
  await this.setReaction(target.messageId, CONSUMED_REACTION);
848
1200
  }
849
1201
  },
1202
+ })
1203
+ .add({
1204
+ name: "session_closed",
1205
+ matches: msg => msg.type === "session_closed",
1206
+ handle: async session => {
1207
+ this.busy.delete(session.sessionId);
1208
+ await this.deleteTopic(session.sessionId);
1209
+ },
850
1210
  });
851
1211
  }
852
1212
 
@@ -877,6 +1237,11 @@ export class TelegramNotificationDaemon {
877
1237
  if (this.sessions.has(sessionId)) continue;
878
1238
  try {
879
1239
  const endpoint = readEndpoint(path.join(dir, file));
1240
+ // Skip endpoint files whose owning process is gone or that are
1241
+ // explicitly stale (e.g. a hard-closed session): reconnecting
1242
+ // would chase a dead, token-bearing record forever.
1243
+ const pidAlive = this.opts.pidAlive ?? defaultPidAlive;
1244
+ if (endpoint.stale || (endpoint.pid !== undefined && !pidAlive(endpoint.pid))) continue;
880
1245
  this.connectSession(sessionId, endpoint.url, endpoint.token);
881
1246
  } catch {}
882
1247
  }
@@ -912,6 +1277,12 @@ export class TelegramNotificationDaemon {
912
1277
  );
913
1278
  } catch {}
914
1279
  }
1280
+ // Eagerly create the session's Telegram topic as soon as it connects, so
1281
+ // a thread exists the moment a notifications-enabled session is live —
1282
+ // not lazily on the first delivered frame (which only arrives once the
1283
+ // user sends a prompt). A provisional "GJC <id>" name is used; the
1284
+ // identity_header frame renames it to "{repo}/{branch} - {title}" later.
1285
+ void this.ensureTopic(sessionId, this.topicNameFor(sessionId, {})).catch(() => undefined);
915
1286
  });
916
1287
  ws.addEventListener("message", ev => {
917
1288
  // Identity guard: a delayed frame from a superseded socket must not act
@@ -1077,8 +1448,12 @@ export class TelegramNotificationDaemon {
1077
1448
  return String(tid);
1078
1449
  },
1079
1450
  this.opts.now,
1451
+ // The create winner records the name it actually used; callers that
1452
+ // merely JOIN an in-flight create must not overwrite it locally, or a
1453
+ // later identity rename would be wrongly skipped (topic stuck at the
1454
+ // provisional name on Telegram).
1455
+ name,
1080
1456
  );
1081
- this.topics.applyName(sessionId, name);
1082
1457
  await this.persistTopics();
1083
1458
  return rec.topicId;
1084
1459
  } catch {
@@ -1086,6 +1461,31 @@ export class TelegramNotificationDaemon {
1086
1461
  }
1087
1462
  }
1088
1463
 
1464
+ /** Best-effort delete of a session topic once its local notification endpoint shuts down. */
1465
+ private async deleteTopic(sessionId: string): Promise<void> {
1466
+ const record = this.topics.get(sessionId);
1467
+ if (!record) return;
1468
+ try {
1469
+ // Drop queued sends for this session before deleting the topic; otherwise
1470
+ // rate-limited frames can flush later into a deleted topic or across resume.
1471
+ this.pool.removeWhere(item => item.sessionId === sessionId);
1472
+ await this.flushPool();
1473
+ const res = (await this.botApi.call("deleteForumTopic", {
1474
+ chat_id: this.opts.chatId,
1475
+ message_thread_id: Number(record.topicId),
1476
+ })) as { ok?: boolean };
1477
+ if (res?.ok === false) return;
1478
+ this.topics.delete(sessionId);
1479
+ this.topicOwnerByIdentity.forEach((ownerSessionId, identityKey) => {
1480
+ if (ownerSessionId === sessionId) this.topicOwnerByIdentity.delete(identityKey);
1481
+ });
1482
+ this.pendingThreadedFrames.delete(sessionId);
1483
+ await this.persistTopics();
1484
+ } catch {
1485
+ // Best-effort: missing Telegram topic permissions must not stop teardown.
1486
+ }
1487
+ }
1488
+
1089
1489
  private async persistTopics(): Promise<void> {
1090
1490
  const paths = daemonPaths(this.opts.settings.getAgentDir());
1091
1491
  await ensureDir(this.fsImpl, paths.dir);
@@ -1434,9 +1834,9 @@ export class TelegramNotificationDaemon {
1434
1834
  summary: msg.summary,
1435
1835
  });
1436
1836
  const options = Array.isArray(msg.options) ? msg.options : [];
1437
- // Daemon keyboards MUST use alias callback data (not reference encodeCallbackData).
1438
- // Labels show one-based numbers; the stored alias answer stays zero-based.
1439
- const inline_keyboard = buildButtonGrid(options, (i: number) =>
1837
+ // Daemon keyboards use alias callback data with compact one-based tap targets;
1838
+ // full option text is rendered in the message body by buildActionMessage.
1839
+ const inline_keyboard = buildCompactChoiceGrid(options, (i: number) =>
1440
1840
  this.aliasTable.put({ sessionId: session.sessionId, actionId: msg.id, answer: i }),
1441
1841
  );
1442
1842
  const result = (await this.botApi.call("sendMessage", {
@@ -1480,6 +1880,20 @@ export class TelegramNotificationDaemon {
1480
1880
  }
1481
1881
 
1482
1882
  async handleTelegramUpdate(update: unknown): Promise<void> {
1883
+ // Session-lifecycle command (/session_*): handled ONLY from the paired chat,
1884
+ // gated before any arg parsing or side effect, and routed through the control
1885
+ // endpoint. Must run before threaded-injection so commands are not treated as
1886
+ // session input.
1887
+ {
1888
+ const m = (update as { update_id?: number; message?: Record<string, unknown> }).message;
1889
+ const chatId = (m?.chat as { id?: unknown } | undefined)?.id;
1890
+ const cmdText = typeof m?.text === "string" ? m.text : undefined;
1891
+ if (m !== undefined && String(chatId) === String(this.opts.chatId) && isLifecycleCommandText(cmdText)) {
1892
+ const updateId = (update as { update_id?: number }).update_id;
1893
+ const threadId = typeof m.message_thread_id === "number" ? (m.message_thread_id as number) : undefined;
1894
+ if (await this.handleLifecycleCommand(cmdText, updateId, threadId)) return;
1895
+ }
1896
+ }
1483
1897
  // Threaded injection: a free-text message in a known topic (not a button
1484
1898
  // tap and not a reply to a specific ask message) injects a user turn or an
1485
1899
  // in-thread config command. Fail-closed: paired chat + known topic +
@@ -1616,6 +2030,9 @@ export class TelegramNotificationDaemon {
1616
2030
  await this.loadAliases();
1617
2031
  await this.loadTopics();
1618
2032
  await this.runScan();
2033
+ // Owner-only: start the session-lifecycle control server now that
2034
+ // ownership is confirmed (singleton-safe). Best-effort; degrades.
2035
+ await this.startLifecycleControl();
1619
2036
  let idleSince = this.runtime.now();
1620
2037
  while (this.running) {
1621
2038
  if (await this.controlStopRequested()) break;
@@ -1631,10 +2048,17 @@ export class TelegramNotificationDaemon {
1631
2048
  break;
1632
2049
  await this.runScan();
1633
2050
  if (await this.controlStopRequested()) break;
1634
- if (this.sessions.size === 0) {
1635
- if (this.runtime.now() - idleSince >= (this.opts.idleTimeoutMs ?? 60_000)) break;
2051
+ const idleElapsed = this.runtime.now() - idleSince >= (this.opts.idleTimeoutMs ?? 60_000);
2052
+ if (this.sessions.size === 0 && !this.lifecycleControlActive) {
2053
+ // No sessions and no lifecycle control: idle-exit on timeout.
2054
+ if (idleElapsed) break;
1636
2055
  } else {
1637
- idleSince = this.runtime.now();
2056
+ // Poll getUpdates when sessions exist OR lifecycle control is active
2057
+ // (so phone /session_* commands are received even with zero sessions).
2058
+ // With zero sessions, still idle-exit after the timeout so the owner
2059
+ // does not run forever; an active session resets the idle window.
2060
+ if (this.sessions.size > 0) idleSince = this.runtime.now();
2061
+ else if (idleElapsed) break;
1638
2062
  const activePoll = this.runtime.createAbortController();
1639
2063
  try {
1640
2064
  await this.pollOnce(activePoll.signal);
@@ -1659,6 +2083,7 @@ export class TelegramNotificationDaemon {
1659
2083
  this.stopFlushTimer();
1660
2084
  this.stopScanTimer();
1661
2085
  this.stopTypingTimer();
2086
+ this.stopLifecycleControl();
1662
2087
  await this.cleanupAllAttachmentDirs();
1663
2088
  // Persist durable state before releasing ownership so a fresh daemon
1664
2089
  // (e.g. after reload) reloads aliases/topics seamlessly.
@@ -15,7 +15,14 @@
15
15
  */
16
16
 
17
17
  import * as fs from "node:fs";
18
- import { bold, buildButtonGrid, escapeHtml, TELEGRAM_PARSE_MODE, truncateTelegramHtml } from "./html-format";
18
+ import {
19
+ bold,
20
+ buildCompactChoiceGrid,
21
+ escapeHtml,
22
+ numberedOptionList,
23
+ TELEGRAM_PARSE_MODE,
24
+ truncateTelegramHtml,
25
+ } from "./html-format";
19
26
  import { renderThreadedFrame } from "./threaded-render";
20
27
 
21
28
  /** One inline-keyboard button. */
@@ -129,8 +136,9 @@ export function buildActionMessage(action: {
129
136
  const text = `❓ ${bold(action.question ?? "Question")}`;
130
137
  const options = action.options ?? [];
131
138
  if (options.length === 0) return { text: truncateTelegramHtml(`${text}\n\n(reply with text)`) };
132
- const inline_keyboard = buildButtonGrid(options, i => encodeCallbackData(action.id, i));
133
- return { text: truncateTelegramHtml(text), inline_keyboard };
139
+ const body = `${text}\n\n${numberedOptionList(options)}`;
140
+ const inline_keyboard = buildCompactChoiceGrid(options, i => encodeCallbackData(action.id, i));
141
+ return { text: truncateTelegramHtml(body), inline_keyboard };
134
142
  }
135
143
 
136
144
  /** A protocol `reply` frame the client should send to the server. */
@@ -235,13 +243,23 @@ export function routeInboundUpdate(update: unknown, ctx: RouteInboundContext): R
235
243
  return { kind: "ignore" };
236
244
  }
237
245
 
238
- /** Read `{url, token}` from an endpoint discovery file. */
239
- export function readEndpoint(path: string): { url: string; token: string } {
240
- const raw = JSON.parse(fs.readFileSync(path, "utf8")) as { url?: unknown; token?: unknown };
246
+ /** Read `{url, token, pid?, stale?}` from an endpoint discovery file. */
247
+ export function readEndpoint(path: string): { url: string; token: string; pid?: number; stale?: boolean } {
248
+ const raw = JSON.parse(fs.readFileSync(path, "utf8")) as {
249
+ url?: unknown;
250
+ token?: unknown;
251
+ pid?: unknown;
252
+ stale?: unknown;
253
+ };
241
254
  if (typeof raw.url !== "string" || typeof raw.token !== "string") {
242
255
  throw new Error(`invalid endpoint file: ${path}`);
243
256
  }
244
- return { url: raw.url, token: raw.token };
257
+ return {
258
+ url: raw.url,
259
+ token: raw.token,
260
+ pid: typeof raw.pid === "number" ? raw.pid : undefined,
261
+ stale: raw.stale === true,
262
+ };
245
263
  }
246
264
 
247
265
  /** Options for {@link runTelegramReferenceClient}. */
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * Per-session forum-topic registry for the threaded session surface.
3
3
  *
4
- * Each GJC session owns exactly one Telegram forum topic in the paired private
5
- * DM. The topic is created once (via `createForumTopic`) and REUSED on resume,
6
- * keyed by session id, so a resumed session streams back into its existing
7
- * thread/history. The registry also tracks whether the one-time identity header
8
- * has already been pinned, so it is sent exactly once per topic even across
4
+ * Each GJC session owns one active Telegram forum topic in the paired private
5
+ * DM. The topic is created via `createForumTopic`, reused while the session
6
+ * remains active, and removed from the registry when the daemon deletes it on
7
+ * shutdown. The registry also tracks whether the one-time identity header has
8
+ * already been pinned, so it is sent exactly once per active topic, even across
9
9
  * reconnects.
10
10
  *
11
11
  * State is a plain serialisable map persisted beside the daemon state files;
@@ -76,14 +76,14 @@ export class TopicRegistry {
76
76
  }
77
77
 
78
78
  /**
79
- * Return the existing topic for `sessionId`, or create one via `create`
80
- * (called only on first use). Reuse-on-resume: an existing record is
81
- * returned without invoking `create`.
79
+ * Return the existing active topic for `sessionId`, or create one via
80
+ * `create` (called only on first use).
82
81
  */
83
82
  async getOrCreateTopic(
84
83
  sessionId: string,
85
84
  create: () => Promise<string>,
86
85
  now: () => number = Date.now,
86
+ name?: string,
87
87
  ): Promise<TopicRecord> {
88
88
  const existing = this.topics.get(sessionId);
89
89
  if (existing) return existing;
@@ -95,7 +95,7 @@ export class TopicRegistry {
95
95
  if (pending) return pending;
96
96
  const promise = (async () => {
97
97
  const topicId = await create();
98
- const record: TopicRecord = { topicId, identitySent: false, createdAt: now() };
98
+ const record: TopicRecord = { topicId, name, identitySent: false, createdAt: now() };
99
99
  this.topics.set(sessionId, record);
100
100
  this.byTopic.set(topicId, sessionId);
101
101
  return record;
@@ -131,6 +131,15 @@ export class TopicRegistry {
131
131
  return true;
132
132
  }
133
133
 
134
+ /** Remove a session topic record after Telegram deletes the topic. */
135
+ delete(sessionId: string): boolean {
136
+ const record = this.topics.get(sessionId);
137
+ if (!record) return false;
138
+ this.topics.delete(sessionId);
139
+ this.byTopic.delete(record.topicId);
140
+ return true;
141
+ }
142
+
134
143
  /** Serialise for atomic persistence beside the daemon state. */
135
144
  serialize(): TopicRegistryState {
136
145
  return { topics: Object.fromEntries(this.topics) };
@@ -36,8 +36,8 @@ This mode activates only when the assignment explicitly labels Executor as Ultra
36
36
 
37
37
  When active:
38
38
  - Start from the approved plan/spec/acceptance criteria, then user-facing contracts, then implementation code only as supporting evidence. Treat plan/code mismatches as blockers.
39
- - Exercise the real user-facing invocation rather than inspecting internals alone. Live artifacts must be runtime-valid: GUI/web needs a real automation transcript plus non-uniform screenshot; CLI needs executed argv-only replay; native/desktop/TUI needs a real screenshot, PTY capture with control codes, or app-automation transcript. `inlineEvidence` is supplemental only and is never sole proof for live surfaces.
40
- - For CLI evidence, emit argv-only replay JSON with `schemaVersion: 1`, `kind: "cli-replay"`, `replaySafe: true`, and `command` as a string array. Use only allowlisted deterministic executables/arguments, or mark unsafe/non-deterministic commands with audited `replayExempt` metadata plus a valid structural fallback artifact. `replayExempt` must use exact fields `reasonCode`, `reason`, `approvedBy`, and `fallbackArtifactRefs`; allowed `reasonCode` values are exactly `unsafe_side_effect`, `requires_credentials`, `requires_network`, `non_deterministic_external`, `destructive`, `interactive_only`, and `platform_unavailable`.
39
+ - Exercise the real user-facing invocation rather than inspecting internals alone. Live artifacts must be runtime-valid: GUI/web needs a real automation transcript plus non-uniform screenshot; CLI needs executed argv-only replay; native/desktop/TUI needs a real screenshot, PTY capture with control codes, or app-automation transcript. API/package surfaces need a real artifact file or typed receipt whose artifact `kind` contains `api`, `package`, `consumer`, `black-box`, or `test-report`; good kinds include `api-package-test-report`, `package-consumer-report`, and `black-box-api-receipt`. Algorithm/math surfaces need a real artifact file or typed receipt whose artifact `kind` contains `property`, `boundary`, `edge`, `adversarial`, `failure`, `math`, `algorithm`, or `test-report`; good kinds include `property-test-report` and `algorithm-boundary-report`. `inlineEvidence` is supplemental only and is never sole proof for live surfaces.
40
+ - For CLI evidence, emit argv-only replay JSON with `schemaVersion: 1`, `kind: "cli-replay"`, `replaySafe: true`, and `command` as a string array. Use only allowlisted deterministic executables/arguments: `bun --version`, `node --version`, deterministic `bun/node -e "console.log(...)"`, `npm|pnpm|yarn --version`, `npm|pnpm|yarn list`, read-only `git status|rev-parse|merge-base|diff|show|log` with safe args, and `gjc read|status`. Mark any other command with audited `replayExempt` metadata plus a valid structural fallback artifact. `replayExempt` must use exact fields `reasonCode`, `reason`, `approvedBy`, and `fallbackArtifactRefs`; allowed `reasonCode` values are exactly `unsafe_side_effect`, `requires_credentials`, `requires_network`, `non_deterministic_external`, `destructive`, `interactive_only`, and `platform_unavailable`.
41
41
  - Native/TUI evidence must be structural, not prose-only: screenshot, app transcript, or PTY artifact with terminal control codes.
42
42
  - Do not call the `ask` tool while an Ultragoal run is active; record unresolved decisions with `gjc ultragoal record-review-blockers`.
43
43
  - Try to break the work with adversarial cases, not just happy-path confirmations.