@gajae-code/coding-agent 0.8.0 → 0.8.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 (116) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/types/cli/completion-cli.d.ts +41 -0
  3. package/dist/types/cli/config-cli.d.ts +1 -0
  4. package/dist/types/cli/notify-cli.d.ts +7 -0
  5. package/dist/types/cli.d.ts +124 -1
  6. package/dist/types/commands/completion.d.ts +29 -0
  7. package/dist/types/commands/config.d.ts +3 -0
  8. package/dist/types/commands/deep-interview.d.ts +3 -0
  9. package/dist/types/config/file-lock.d.ts +7 -6
  10. package/dist/types/config/keybindings.d.ts +2 -2
  11. package/dist/types/config/model-profile-activation.d.ts +6 -0
  12. package/dist/types/config/settings.d.ts +8 -0
  13. package/dist/types/extensibility/extensions/runner.d.ts +2 -1
  14. package/dist/types/extensibility/extensions/types.d.ts +8 -0
  15. package/dist/types/gjc-runtime/deep-interview-ambiguity.d.ts +39 -0
  16. package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +10 -1
  17. package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +1 -1
  18. package/dist/types/gjc-runtime/deep-interview-state.d.ts +12 -0
  19. package/dist/types/gjc-runtime/team-runtime.d.ts +2 -1
  20. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +1 -0
  21. package/dist/types/main.d.ts +4 -1
  22. package/dist/types/modes/components/model-selector.d.ts +4 -0
  23. package/dist/types/modes/components/queued-message-selector.d.ts +12 -0
  24. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  25. package/dist/types/modes/controllers/input-controller.d.ts +1 -3
  26. package/dist/types/modes/print-mode.d.ts +13 -1
  27. package/dist/types/modes/prompt-action-autocomplete.d.ts +1 -1
  28. package/dist/types/notifications/config.d.ts +8 -1
  29. package/dist/types/notifications/daemon-paths.d.ts +1 -0
  30. package/dist/types/notifications/index.d.ts +6 -3
  31. package/dist/types/notifications/lifecycle-commands.d.ts +11 -3
  32. package/dist/types/notifications/telegram-daemon.d.ts +18 -3
  33. package/dist/types/notifications/telegram-reference.d.ts +0 -5
  34. package/dist/types/runtime-api-key.d.ts +7 -0
  35. package/dist/types/session/agent-session.d.ts +10 -0
  36. package/dist/types/session/agent-storage.d.ts +1 -0
  37. package/dist/types/task/executor.d.ts +2 -0
  38. package/dist/types/tools/computer.d.ts +2 -2
  39. package/dist/types/tools/skill.d.ts +1 -1
  40. package/dist/types/workflow/workflow-intent-diff.d.ts +8 -2
  41. package/dist/types/workflow/workflow-intent-report-consumer.d.ts +50 -0
  42. package/dist/types/workflow/workflow-intent-report-schema.d.ts +2 -0
  43. package/dist/types/workflow/workflow-intent-report.d.ts +48 -0
  44. package/package.json +7 -7
  45. package/src/cli/completion-cli.ts +234 -0
  46. package/src/cli/config-cli.ts +37 -9
  47. package/src/cli/fast-help.ts +1 -0
  48. package/src/cli/notify-cli.ts +91 -7
  49. package/src/cli.ts +3 -1
  50. package/src/commands/completion.ts +84 -0
  51. package/src/commands/config.ts +2 -0
  52. package/src/commands/deep-interview.ts +2 -1
  53. package/src/config/file-lock.ts +73 -34
  54. package/src/config/keybindings.ts +2 -2
  55. package/src/config/model-profile-activation.ts +65 -0
  56. package/src/config/model-registry.ts +14 -2
  57. package/src/config/settings.ts +22 -0
  58. package/src/coordinator-mcp/server.ts +67 -12
  59. package/src/defaults/gjc/skills/deep-interview/SKILL.md +38 -5
  60. package/src/defaults/gjc/skills/ralplan/SKILL.md +15 -11
  61. package/src/defaults/gjc/skills/team/SKILL.md +1 -1
  62. package/src/extensibility/extensions/runner.ts +2 -0
  63. package/src/extensibility/extensions/types.ts +9 -0
  64. package/src/extensibility/skills.ts +30 -2
  65. package/src/gjc-runtime/deep-interview-ambiguity.ts +206 -0
  66. package/src/gjc-runtime/deep-interview-recorder.ts +62 -8
  67. package/src/gjc-runtime/deep-interview-runtime.ts +210 -2
  68. package/src/gjc-runtime/deep-interview-state.ts +13 -0
  69. package/src/gjc-runtime/session-state-sidecar.ts +59 -11
  70. package/src/gjc-runtime/state-runtime.ts +12 -2
  71. package/src/gjc-runtime/team-runtime.ts +23 -0
  72. package/src/gjc-runtime/ultragoal-runtime.ts +73 -19
  73. package/src/hooks/native-skill-hook.ts +29 -0
  74. package/src/internal-urls/docs-index.generated.ts +2 -2
  75. package/src/main.ts +52 -41
  76. package/src/modes/acp/acp-agent.ts +7 -6
  77. package/src/modes/components/model-selector.ts +54 -11
  78. package/src/modes/components/queued-message-selector.ts +92 -0
  79. package/src/modes/components/settings-selector.ts +4 -0
  80. package/src/modes/components/welcome.ts +160 -44
  81. package/src/modes/controllers/command-controller.ts +4 -4
  82. package/src/modes/controllers/extension-ui-controller.ts +4 -10
  83. package/src/modes/controllers/input-controller.ts +186 -25
  84. package/src/modes/controllers/selector-controller.ts +122 -27
  85. package/src/modes/interactive-mode.ts +43 -16
  86. package/src/modes/print-mode.ts +43 -4
  87. package/src/modes/prompt-action-autocomplete.ts +24 -13
  88. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  89. package/src/modes/utils/ui-helpers.ts +2 -2
  90. package/src/notifications/config.ts +9 -1
  91. package/src/notifications/daemon-paths.ts +2 -0
  92. package/src/notifications/index.ts +39 -9
  93. package/src/notifications/lifecycle-commands.ts +37 -7
  94. package/src/notifications/lifecycle-orchestrator.ts +19 -6
  95. package/src/notifications/telegram-daemon-control.ts +3 -1
  96. package/src/notifications/telegram-daemon.ts +231 -43
  97. package/src/notifications/telegram-reference.ts +0 -16
  98. package/src/prompts/system/system-prompt.md +3 -0
  99. package/src/prompts/tools/computer.md +2 -2
  100. package/src/runtime-api-key.ts +16 -0
  101. package/src/sdk.ts +20 -6
  102. package/src/session/agent-session.ts +225 -31
  103. package/src/session/agent-storage.ts +4 -0
  104. package/src/session/session-manager.ts +44 -2
  105. package/src/slash-commands/builtin-registry.ts +153 -71
  106. package/src/task/executor.ts +7 -4
  107. package/src/task/index.ts +9 -3
  108. package/src/task/output-manager.ts +16 -6
  109. package/src/task/receipt.ts +26 -7
  110. package/src/tools/computer.ts +246 -43
  111. package/src/tools/skill.ts +1 -1
  112. package/src/tools/telegram-send.ts +6 -0
  113. package/src/workflow/workflow-intent-diff.ts +29 -6
  114. package/src/workflow/workflow-intent-report-consumer.ts +221 -0
  115. package/src/workflow/workflow-intent-report-schema.ts +129 -0
  116. package/src/workflow/workflow-intent-report.ts +135 -0
@@ -27,6 +27,7 @@ import type {
27
27
  } from "./index";
28
28
  import {
29
29
  formatLifecycleOutcome,
30
+ isLifecycleCommandLikeText,
30
31
  isLifecycleCommandText,
31
32
  lifecycleUsage,
32
33
  parseLifecycleCommand,
@@ -48,7 +49,6 @@ import {
48
49
  buildActionMessage,
49
50
  type CallbackRoute,
50
51
  createAliasTable,
51
- type PendingAsk,
52
52
  readEndpoint,
53
53
  routeInboundUpdate,
54
54
  } from "./telegram-reference";
@@ -56,7 +56,7 @@ import { decideThreadedInbound, type InboundAttachment } from "./threaded-inboun
56
56
  import { renderThreadedFrame, type ThreadedSend } from "./threaded-render";
57
57
  import { TopicRegistry, type TopicRegistryState } from "./topic-registry";
58
58
 
59
- export type EnsureDaemonResult = "owner_spawned" | "attached" | "disabled";
59
+ export type EnsureDaemonResult = "owner_spawned" | "attached" | "disabled" | "blocked";
60
60
 
61
61
  export interface DaemonState {
62
62
  pid: number;
@@ -126,6 +126,7 @@ const TYPING_REFRESH_INTERVAL_MS = 4_000;
126
126
  // messages: queued on receipt, consumed once a turn picks the message up.
127
127
  const QUEUED_REACTION = "👀";
128
128
  const PENDING_TOPIC_FRAME_LIMIT = 20;
129
+ const SEEN_UPDATE_ID_LIMIT = 1_000;
129
130
  const CONSUMED_REACTION = "✅";
130
131
 
131
132
  function splitTelegramPlainText(text: string, max = TELEGRAM_MESSAGE_LIMIT): string[] {
@@ -142,6 +143,9 @@ function splitTelegramPlainText(text: string, max = TELEGRAM_MESSAGE_LIMIT): str
142
143
  if (out) chunks.push(out);
143
144
  return chunks;
144
145
  }
146
+ function endpointGenerationKey(url: string, token: string): string {
147
+ return `${url}\0${token}`;
148
+ }
145
149
 
146
150
  /**
147
151
  * Whether `err` is a transient network failure worth retrying. Telegram API
@@ -264,7 +268,7 @@ export async function registerNotificationRoot(input: {
264
268
  const fsImpl = input.fs ?? nodeFs;
265
269
  const paths = daemonPaths(input.settings.getAgentDir());
266
270
  await ensureDir(fsImpl, paths.dir);
267
- const root = path.join(input.cwd, ".gjc", "state");
271
+ const root = notificationRootForCwd(input.cwd);
268
272
  await withFileLock(
269
273
  paths.roots,
270
274
  async () => {
@@ -283,6 +287,29 @@ export async function registerNotificationRoot(input: {
283
287
  return root;
284
288
  }
285
289
 
290
+ function notificationRootForCwd(cwd: string): string {
291
+ return path.join(cwd, ".gjc", "state");
292
+ }
293
+
294
+ function ownerIdentityMatches(state: DaemonState, tokenFingerprint: string, chatId: string): boolean {
295
+ return state.tokenFingerprint === tokenFingerprint && state.chatId === chatId;
296
+ }
297
+
298
+ function liveOwnerUsesDifferentIdentity(input: {
299
+ state: DaemonState | undefined;
300
+ tokenFingerprint: string;
301
+ chatId: string;
302
+ pidAlive: (pid: number) => boolean;
303
+ }): boolean {
304
+ const { state } = input;
305
+ return Boolean(
306
+ state &&
307
+ state.version === DAEMON_VERSION &&
308
+ !ownerIdentityMatches(state, input.tokenFingerprint, input.chatId) &&
309
+ input.pidAlive(state.pid),
310
+ );
311
+ }
312
+
286
313
  export function isFreshLiveOwner(input: {
287
314
  state: DaemonState | undefined;
288
315
  now: number;
@@ -294,8 +321,7 @@ export function isFreshLiveOwner(input: {
294
321
  return Boolean(
295
322
  state &&
296
323
  state.version === DAEMON_VERSION &&
297
- state.tokenFingerprint === input.tokenFingerprint &&
298
- state.chatId === input.chatId &&
324
+ ownerIdentityMatches(state, input.tokenFingerprint, input.chatId) &&
299
325
  input.now - state.heartbeatAt <= HEARTBEAT_TTL_MS &&
300
326
  input.pidAlive(state.pid),
301
327
  );
@@ -311,7 +337,13 @@ export async function acquireDaemonOwnership(input: {
311
337
  pid?: number;
312
338
  pidAlive?: (pid: number) => boolean;
313
339
  randomId?: () => string;
314
- }): Promise<{ acquired: boolean; ownerId?: string; attached?: boolean }> {
340
+ }): Promise<{
341
+ acquired: boolean;
342
+ ownerId?: string;
343
+ attached?: boolean;
344
+ blocked?: boolean;
345
+ reason?: "identity_mismatch";
346
+ }> {
315
347
  const fsImpl = input.fs ?? nodeFs;
316
348
  const now = input.now ?? Date.now;
317
349
  const pid = input.pid ?? process.pid;
@@ -321,6 +353,16 @@ export async function acquireDaemonOwnership(input: {
321
353
  const ownerId = input.randomId?.() ?? `${pid}-${now().toString(36)}-${Math.random().toString(36).slice(2)}`;
322
354
  const roots = input.roots ?? (await readJson<{ roots?: string[] }>(fsImpl, paths.roots))?.roots ?? [];
323
355
  const existing = await readJson<DaemonState>(fsImpl, paths.state);
356
+ if (
357
+ liveOwnerUsesDifferentIdentity({
358
+ state: existing,
359
+ tokenFingerprint: input.tokenFingerprint,
360
+ chatId: input.chatId,
361
+ pidAlive,
362
+ })
363
+ ) {
364
+ return { acquired: false, blocked: true, reason: "identity_mismatch" };
365
+ }
324
366
  if (
325
367
  isFreshLiveOwner({
326
368
  state: existing,
@@ -346,6 +388,16 @@ export async function acquireDaemonOwnership(input: {
346
388
  return { acquired: true, ownerId };
347
389
  }
348
390
  const afterLock = await readJson<DaemonState>(fsImpl, paths.state);
391
+ if (
392
+ liveOwnerUsesDifferentIdentity({
393
+ state: afterLock,
394
+ tokenFingerprint: input.tokenFingerprint,
395
+ chatId: input.chatId,
396
+ pidAlive,
397
+ })
398
+ ) {
399
+ return { acquired: false, blocked: true, reason: "identity_mismatch" };
400
+ }
349
401
  if (
350
402
  isFreshLiveOwner({
351
403
  state: afterLock,
@@ -372,6 +424,16 @@ export async function acquireDaemonOwnership(input: {
372
424
  ) {
373
425
  return { acquired: false, attached: true };
374
426
  }
427
+ if (
428
+ liveOwnerUsesDifferentIdentity({
429
+ state: rechecked,
430
+ tokenFingerprint: input.tokenFingerprint,
431
+ chatId: input.chatId,
432
+ pidAlive,
433
+ })
434
+ ) {
435
+ return { acquired: false, blocked: true, reason: "identity_mismatch" };
436
+ }
375
437
  if (rechecked && pidAlive(rechecked.pid)) {
376
438
  return { acquired: false, attached: true };
377
439
  }
@@ -549,7 +611,16 @@ export async function spawnTelegramDaemonOwner(
549
611
  ownerId: ownership.ownerId ?? "",
550
612
  agentDir,
551
613
  });
552
- if (!ownership.acquired) return { result: "attached", runtime, warnings: [] };
614
+ if (!ownership.acquired) {
615
+ if (ownership.blocked) {
616
+ return {
617
+ result: "blocked",
618
+ runtime,
619
+ warnings: ["live telegram daemon uses a different bot token or chat; refusing to attach"],
620
+ };
621
+ }
622
+ return { result: "attached", runtime, warnings: [] };
623
+ }
553
624
  const spawnImpl = deps.spawn ?? defaultDaemonSpawn;
554
625
  const child = spawnImpl(command, args, {
555
626
  detached: true,
@@ -566,12 +637,17 @@ export async function ensureTelegramDaemonRunning(
566
637
  ): Promise<EnsureDaemonResult> {
567
638
  const cfg = getNotificationConfig(input.settings);
568
639
  if (!isGloballyConfigured(cfg) || !cfg.botToken || !cfg.chatId) return "disabled";
569
- const root = await registerNotificationRoot({ ...input, fs: deps.fs });
640
+ const root = notificationRootForCwd(input.cwd);
570
641
  const fp = tokenFingerprint(cfg.botToken);
571
642
  const spawned = await spawnTelegramDaemonOwner(
572
643
  { settings: input.settings, roots: [root], tokenFingerprint: fp, chatId: cfg.chatId },
573
644
  deps,
574
645
  );
646
+ if (spawned.result === "blocked") {
647
+ logger.warn(`notifications: failed to ensure Telegram daemon: ${spawned.warnings.join("; ")}`);
648
+ return spawned.result;
649
+ }
650
+ await registerNotificationRoot({ ...input, fs: deps.fs });
575
651
  return spawned.result;
576
652
  }
577
653
 
@@ -772,6 +848,7 @@ export interface TelegramDaemonOptions {
772
848
  interface SessionSocket {
773
849
  sessionId: string;
774
850
  token: string;
851
+ endpointKey: string;
775
852
  ws: WebSocket;
776
853
  pending: Map<string, { sessionId: string; actionId: string }>;
777
854
  /** True once the server advertised the `client_ping_pong` capability. */
@@ -808,12 +885,16 @@ export class TelegramNotificationDaemon {
808
885
  private readonly topicOwnerByIdentity = new Map<string, string>();
809
886
  /** Non-identity frames held until identity creates the correct thread. */
810
887
  private readonly pendingThreadedFrames = new Map<string, PendingThreadedFrame[]>();
888
+ /** Endpoint generation tombstones for sessions that already sent session_closed. */
889
+ private readonly closedEndpointKeys = new Map<string, string>();
811
890
  /** True once the daemon has nudged the user to enable Threaded Mode. */
812
891
  private threadedFallbackNoticeSent = false;
813
892
  /** Sessions whose identity header was already sent flat (Threaded Mode off). */
814
893
  private readonly flatIdentitySent = new Set<string>();
815
894
  /** Cached result of whether the paired chat is a private chat (flat-fallback gate). */
816
895
  private pairedChatPrivate: boolean | undefined;
896
+ /** Bot username from getMe, cached once at owner startup for group/forum command targeting. */
897
+ private botUsername: string | undefined;
817
898
  /** Sessions whose agent loop is currently busy (drives the typing indicator). */
818
899
  private get busy(): Set<string> {
819
900
  return this.dispatchState.busy;
@@ -1072,8 +1153,10 @@ export class TelegramNotificationDaemon {
1072
1153
  text: string | undefined,
1073
1154
  updateId: number | undefined,
1074
1155
  threadId: number | undefined,
1156
+ commandCtx: { chatType?: string; botUsername?: string },
1075
1157
  ): Promise<boolean> {
1076
- if (!isLifecycleCommandText(text)) return false;
1158
+ if (!isLifecycleCommandText(text, commandCtx)) return false;
1159
+ if (!(await this.pairedChatIsPrivate())) return true;
1077
1160
  const reply = async (body: string): Promise<void> => {
1078
1161
  for (const text of splitTelegramPlainText(body)) {
1079
1162
  await this.botApi
@@ -1098,15 +1181,15 @@ export class TelegramNotificationDaemon {
1098
1181
  }
1099
1182
  };
1100
1183
 
1184
+ const parsed = parseLifecycleCommand(text, commandCtx);
1185
+ if (parsed.kind === "none") return false;
1101
1186
  if (!this.lifecycleControlActive) {
1102
1187
  await reply("Session lifecycle control is not available right now.");
1103
1188
  return true;
1104
1189
  }
1105
1190
  if (updateId !== undefined && this.dispatchState.seenUpdateIds.has(updateId)) return true;
1106
- if (updateId !== undefined) this.dispatchState.seenUpdateIds.add(updateId);
1191
+ if (updateId !== undefined) await this.rememberSeenUpdateId(updateId);
1107
1192
 
1108
- const parsed = parseLifecycleCommand(text);
1109
- if (parsed.kind === "none") return false;
1110
1193
  if (parsed.kind === "usage" || parsed.kind === "reject") {
1111
1194
  await reply(parsed.message);
1112
1195
  return true;
@@ -1139,6 +1222,17 @@ export class TelegramNotificationDaemon {
1139
1222
  return true;
1140
1223
  }
1141
1224
 
1225
+ private async refreshBotIdentity(): Promise<void> {
1226
+ try {
1227
+ const response = (await this.botApi.call("getMe", {})) as { result?: { username?: unknown } };
1228
+ const username = response.result?.username;
1229
+ this.botUsername =
1230
+ typeof username === "string" && username.trim() ? username.trim().replace(/^@/, "") : undefined;
1231
+ } catch {
1232
+ this.botUsername = undefined;
1233
+ }
1234
+ }
1235
+
1142
1236
  /** Map a lifecycle response/error to a user-facing message (G010 surfacing). */
1143
1237
  private formatLifecycleResponse(r: SessionLifecycleResponse): string {
1144
1238
  return formatLifecycleOutcome(r);
@@ -1223,7 +1317,9 @@ export class TelegramNotificationDaemon {
1223
1317
  matches: msg => msg.type === "session_closed",
1224
1318
  handle: async session => {
1225
1319
  this.busy.delete(session.sessionId);
1320
+ this.closedEndpointKeys.set(session.sessionId, session.endpointKey);
1226
1321
  await this.deleteTopic(session.sessionId);
1322
+ this.dropSession(session, "session_closed");
1227
1323
  },
1228
1324
  });
1229
1325
  }
@@ -1239,6 +1335,51 @@ export class TelegramNotificationDaemon {
1239
1335
  await writeJsonAtomic(this.fsImpl, paths.aliases, this.aliasTable.serialize());
1240
1336
  }
1241
1337
 
1338
+ async loadSeenUpdateIds(): Promise<void> {
1339
+ const raw = await readJson<{ updateIds?: unknown }>(
1340
+ this.fsImpl,
1341
+ daemonPaths(this.opts.settings.getAgentDir()).seenUpdates,
1342
+ );
1343
+ this.dispatchState.seenUpdateIds.clear();
1344
+ const updateIds = Array.isArray(raw?.updateIds) ? raw.updateIds : [];
1345
+ for (const updateId of updateIds) {
1346
+ if (Number.isSafeInteger(updateId) && Number(updateId) >= 0) {
1347
+ this.dispatchState.seenUpdateIds.add(Number(updateId));
1348
+ }
1349
+ }
1350
+ this.pruneSeenUpdateIds();
1351
+ }
1352
+
1353
+ async persistSeenUpdateIds(): Promise<void> {
1354
+ const paths = daemonPaths(this.opts.settings.getAgentDir());
1355
+ await ensureDir(this.fsImpl, paths.dir);
1356
+ await writeJsonAtomic(this.fsImpl, paths.seenUpdates, {
1357
+ version: 1,
1358
+ updateIds: [...this.dispatchState.seenUpdateIds].slice(-SEEN_UPDATE_ID_LIMIT),
1359
+ });
1360
+ }
1361
+
1362
+ private pruneSeenUpdateIds(): void {
1363
+ let extra = this.dispatchState.seenUpdateIds.size - SEEN_UPDATE_ID_LIMIT;
1364
+ if (extra <= 0) return;
1365
+ for (const updateId of this.dispatchState.seenUpdateIds) {
1366
+ this.dispatchState.seenUpdateIds.delete(updateId);
1367
+ extra -= 1;
1368
+ if (extra <= 0) break;
1369
+ }
1370
+ }
1371
+
1372
+ private async rememberSeenUpdateId(updateId: number): Promise<void> {
1373
+ if (!Number.isSafeInteger(updateId) || updateId < 0) return;
1374
+ this.dispatchState.seenUpdateIds.add(updateId);
1375
+ this.pruneSeenUpdateIds();
1376
+ try {
1377
+ await this.persistSeenUpdateIds();
1378
+ } catch (err) {
1379
+ logger.warn(`notifications: failed to persist Telegram update id ${updateId}: ${String(err)}`);
1380
+ }
1381
+ }
1382
+
1242
1383
  async scanRoots(): Promise<void> {
1243
1384
  const paths = daemonPaths(this.opts.settings.getAgentDir());
1244
1385
  const rootState = await readJson<{ roots?: string[] }>(this.fsImpl, paths.roots);
@@ -1260,6 +1401,9 @@ export class TelegramNotificationDaemon {
1260
1401
  // would chase a dead, token-bearing record forever.
1261
1402
  const pidAlive = this.opts.pidAlive ?? defaultPidAlive;
1262
1403
  if (endpoint.stale || (endpoint.pid !== undefined && !pidAlive(endpoint.pid))) continue;
1404
+ const endpointKey = endpointGenerationKey(endpoint.url, endpoint.token);
1405
+ if (this.closedEndpointKeys.get(sessionId) === endpointKey) continue;
1406
+ this.closedEndpointKeys.delete(sessionId);
1263
1407
  this.connectSession(sessionId, endpoint.url, endpoint.token);
1264
1408
  } catch {}
1265
1409
  }
@@ -1269,9 +1413,12 @@ export class TelegramNotificationDaemon {
1269
1413
  connectSession(sessionId: string, url: string, token: string): void {
1270
1414
  const WS = this.opts.WebSocketImpl ?? WebSocket;
1271
1415
  const ws = new WS(`${url}/?token=${encodeURIComponent(token)}`);
1416
+ const endpointKey = endpointGenerationKey(url, token);
1417
+ this.closedEndpointKeys.delete(sessionId);
1272
1418
  const session: SessionSocket = {
1273
1419
  sessionId,
1274
1420
  token,
1421
+ endpointKey,
1275
1422
  ws,
1276
1423
  pending: new Map(),
1277
1424
  capable: false,
@@ -1352,13 +1499,17 @@ export class TelegramNotificationDaemon {
1352
1499
  * (so a delayed old close cannot delete a replacement), and best-effort closes
1353
1500
  * the socket. `scanRoots()` then reconnects the session.
1354
1501
  */
1355
- private dropSession(session: SessionSocket, _reason: string): void {
1502
+ private dropSession(session: SessionSocket, reason: string): void {
1356
1503
  const clearIntervalImpl = this.opts.clearIntervalImpl ?? clearInterval;
1357
1504
  if (session.pingTimer) {
1358
1505
  clearIntervalImpl(session.pingTimer);
1359
1506
  session.pingTimer = undefined;
1360
1507
  }
1361
- if (this.sessions.get(session.sessionId) === session) {
1508
+ const isCurrentSession = this.sessions.get(session.sessionId) === session;
1509
+ if (isCurrentSession || reason === "session_closed") {
1510
+ this.deleteMessageRoutes(session.sessionId);
1511
+ }
1512
+ if (isCurrentSession) {
1362
1513
  this.sessions.delete(session.sessionId);
1363
1514
  }
1364
1515
  if (session.ws.readyState !== WebSocket.CLOSED) {
@@ -1368,6 +1519,14 @@ export class TelegramNotificationDaemon {
1368
1519
  }
1369
1520
  }
1370
1521
 
1522
+ private deleteMessageRoutes(sessionId: string, actionId?: string): void {
1523
+ for (const [messageId, route] of this.messageRoutes.entries()) {
1524
+ if (route.sessionId === sessionId && (actionId === undefined || route.actionId === actionId)) {
1525
+ this.messageRoutes.delete(messageId);
1526
+ }
1527
+ }
1528
+ }
1529
+
1371
1530
  private static readonly THREADED_FRAMES = new Set([
1372
1531
  "identity_header",
1373
1532
  "context_update",
@@ -1420,6 +1579,13 @@ export class TelegramNotificationDaemon {
1420
1579
  return undefined;
1421
1580
  }
1422
1581
 
1582
+ private sessionCanClaimIdentity(session: SessionSocket, msg: { repo?: unknown; branch?: unknown }): boolean {
1583
+ const current = this.sessions.get(session.sessionId);
1584
+ if (current) return current === session;
1585
+ const ownerId = this.topicOwnerForIdentity(msg);
1586
+ return !ownerId || ownerId === session.sessionId;
1587
+ }
1588
+
1423
1589
  private async submitThreadedFrame(sessionId: string, send: ThreadedSend, topicId: string): Promise<void> {
1424
1590
  this.pool.submit({
1425
1591
  sessionId,
@@ -1430,6 +1596,11 @@ export class TelegramNotificationDaemon {
1430
1596
  await this.flushPool();
1431
1597
  }
1432
1598
 
1599
+ private async existingTopicForPrivateChat(sessionId: string): Promise<string | undefined> {
1600
+ if (!(await this.pairedChatIsPrivate())) return undefined;
1601
+ return this.topics.get(sessionId)?.topicId;
1602
+ }
1603
+
1433
1604
  private rememberPendingThreadedFrame(sessionId: string, send: ThreadedSend, msg: Record<string, unknown>): void {
1434
1605
  const frames = this.pendingThreadedFrames.get(sessionId) ?? [];
1435
1606
  frames.push({ send, msg });
@@ -1451,6 +1622,7 @@ export class TelegramNotificationDaemon {
1451
1622
  * one-time nudge) or drop fail-closed for a non-private chat.
1452
1623
  */
1453
1624
  private async ensureTopic(sessionId: string, name: string): Promise<string | undefined> {
1625
+ if (!(await this.pairedChatIsPrivate())) return undefined;
1454
1626
  const existing = this.topics.get(sessionId);
1455
1627
  if (existing) return existing.topicId;
1456
1628
  try {
@@ -1625,6 +1797,7 @@ export class TelegramNotificationDaemon {
1625
1797
  private async flushPool(): Promise<void> {
1626
1798
  for (const item of this.pool.drain()) {
1627
1799
  const { send, topicId } = item.payload;
1800
+ if (topicId && !(await this.pairedChatIsPrivate())) continue;
1628
1801
  // Threaded topic when available; otherwise deliver flat to the paired chat.
1629
1802
  const threadField = topicId ? { message_thread_id: Number(topicId) } : {};
1630
1803
  try {
@@ -1683,9 +1856,10 @@ export class TelegramNotificationDaemon {
1683
1856
  }
1684
1857
 
1685
1858
  /**
1686
- * Resolve once (cached) whether the paired `chatId` is a private chat. Flat
1687
- * fallback is only safe in a private DM; any non-private chat or an unresolvable
1688
- * `getChat` is treated as not-private so delivery fails closed.
1859
+ * Resolve (and cache successful resolution of) whether the paired `chatId` is a
1860
+ * private chat. Topic and flat delivery are only safe in a private DM; any
1861
+ * non-private chat fails closed, while a transient `getChat` failure fails closed
1862
+ * for the current attempt and is retried later.
1689
1863
  */
1690
1864
  private async pairedChatIsPrivate(): Promise<boolean> {
1691
1865
  if (this.pairedChatPrivate !== undefined) return this.pairedChatPrivate;
@@ -1694,15 +1868,16 @@ export class TelegramNotificationDaemon {
1694
1868
  result?: { type?: string };
1695
1869
  };
1696
1870
  this.pairedChatPrivate = res.result?.type === "private";
1697
- } catch {
1698
- this.pairedChatPrivate = false;
1871
+ return this.pairedChatPrivate;
1872
+ } catch (e) {
1873
+ logger.warn(`notifications: getChat failed while checking Telegram chat privacy: ${String(e)}`);
1874
+ return false;
1699
1875
  }
1700
- return this.pairedChatPrivate;
1701
1876
  }
1702
1877
 
1703
1878
  /** Tell the user once (per daemon run) how to enable Threaded Mode. */
1704
1879
  private async notifyThreadedFallback(): Promise<void> {
1705
- if (this.threadedFallbackNoticeSent) return;
1880
+ if (this.threadedFallbackNoticeSent || !(await this.pairedChatIsPrivate())) return;
1706
1881
  this.threadedFallbackNoticeSent = true;
1707
1882
  try {
1708
1883
  await this.botApi.call("sendMessage", {
@@ -1747,7 +1922,7 @@ export class TelegramNotificationDaemon {
1747
1922
  /** Send a single `typing` chat action into a busy session's topic (best-effort). */
1748
1923
  private async sendTyping(sessionId: string): Promise<void> {
1749
1924
  const topicId = this.topics.get(sessionId)?.topicId;
1750
- if (!topicId) return;
1925
+ if (!topicId || !(await this.pairedChatIsPrivate())) return;
1751
1926
  try {
1752
1927
  await this.botApi.call("sendChatAction", {
1753
1928
  chat_id: this.opts.chatId,
@@ -1761,6 +1936,7 @@ export class TelegramNotificationDaemon {
1761
1936
 
1762
1937
  /** Set a native reaction on an inbound thread message (best-effort). */
1763
1938
  private async setReaction(messageId: number, emoji: string): Promise<void> {
1939
+ if (!(await this.pairedChatIsPrivate())) return;
1764
1940
  try {
1765
1941
  await this.botApi.call("setMessageReaction", {
1766
1942
  chat_id: this.opts.chatId,
@@ -1788,12 +1964,12 @@ export class TelegramNotificationDaemon {
1788
1964
  if (typeof msg?.type === "string" && TelegramNotificationDaemon.THREADED_FRAMES.has(msg.type)) {
1789
1965
  const send = renderThreadedFrame(msg);
1790
1966
  if (!send) return;
1791
- const existingTopic = this.topics.get(session.sessionId)?.topicId;
1967
+ const existingTopic = await this.existingTopicForPrivateChat(session.sessionId);
1792
1968
  if (!send.identity && !existingTopic && !this.flatIdentitySent.has(session.sessionId)) {
1793
1969
  this.rememberPendingThreadedFrame(session.sessionId, send, msg as Record<string, unknown>);
1794
1970
  return;
1795
1971
  }
1796
- if (send.identity) {
1972
+ if (send.identity && !this.sessionCanClaimIdentity(session, msg)) {
1797
1973
  const ownerId = this.topicOwnerForIdentity(msg);
1798
1974
  const ownerTopic = ownerId ? this.topics.get(ownerId) : undefined;
1799
1975
  if (ownerId && ownerId !== session.sessionId && ownerTopic) {
@@ -1876,6 +2052,7 @@ export class TelegramNotificationDaemon {
1876
2052
  await this.persistAliases();
1877
2053
  } else if (msg.type === "action_resolved" && msg.id) {
1878
2054
  session.pending.delete(msg.id);
2055
+ this.deleteMessageRoutes(session.sessionId, msg.id);
1879
2056
  for (const [alias, route] of this.aliasTable.entries()) {
1880
2057
  if (route.sessionId === session.sessionId && route.actionId === msg.id) this.aliasTable.delete(alias);
1881
2058
  }
@@ -1883,19 +2060,22 @@ export class TelegramNotificationDaemon {
1883
2060
  }
1884
2061
  }
1885
2062
 
1886
- pendingBySession = (sessionId?: string): PendingAsk[] => {
1887
- const result: PendingAsk[] = [];
1888
- for (const session of this.sessions.values()) {
1889
- if (sessionId && session.sessionId !== sessionId) continue;
1890
- result.push(...session.pending.values());
2063
+ private async answerCallbackQueryBestEffort(callbackId: unknown, text?: string): Promise<void> {
2064
+ if (typeof callbackId !== "string") return;
2065
+ try {
2066
+ await this.botApi.call("answerCallbackQuery", {
2067
+ callback_query_id: callbackId,
2068
+ ...(text === undefined ? {} : { text }),
2069
+ });
2070
+ } catch {
2071
+ // Telegram callback acknowledgements only dismiss the client-side spinner;
2072
+ // they must never block the already-validated local reply path.
1891
2073
  }
1892
- return result;
1893
- };
2074
+ }
1894
2075
 
1895
2076
  private async sendStaleGuidance(callbackId: unknown): Promise<void> {
1896
- if (typeof callbackId === "string") {
1897
- await this.botApi.call("answerCallbackQuery", { callback_query_id: callbackId, text: "Button is stale" });
1898
- }
2077
+ await this.answerCallbackQueryBestEffort(callbackId, "Button is stale");
2078
+ if (!(await this.pairedChatIsPrivate())) return;
1899
2079
  await this.botApi.call("sendMessage", {
1900
2080
  chat_id: this.opts.chatId,
1901
2081
  text: "This button is stale after notification daemon restart. Please answer locally in the GJC session or wait for a fresh notification.",
@@ -1910,12 +2090,18 @@ export class TelegramNotificationDaemon {
1910
2090
  // session input.
1911
2091
  {
1912
2092
  const m = (update as { update_id?: number; message?: Record<string, unknown> }).message;
1913
- const chatId = (m?.chat as { id?: unknown } | undefined)?.id;
2093
+ const chat = m?.chat as { id?: unknown; type?: unknown } | undefined;
2094
+ const chatId = chat?.id;
2095
+ const chatType = typeof chat?.type === "string" ? chat.type : undefined;
1914
2096
  const cmdText = typeof m?.text === "string" ? m.text : undefined;
1915
- if (m !== undefined && String(chatId) === String(this.opts.chatId) && isLifecycleCommandText(cmdText)) {
1916
- const updateId = (update as { update_id?: number }).update_id;
1917
- const threadId = typeof m.message_thread_id === "number" ? (m.message_thread_id as number) : undefined;
1918
- if (await this.handleLifecycleCommand(cmdText, updateId, threadId)) return;
2097
+ const commandCtx = { chatType, botUsername: this.botUsername };
2098
+ if (m !== undefined && String(chatId) === String(this.opts.chatId)) {
2099
+ if (chatType !== undefined && chatType !== "private" && isLifecycleCommandLikeText(cmdText)) return;
2100
+ if (isLifecycleCommandText(cmdText, commandCtx)) {
2101
+ const updateId = (update as { update_id?: number }).update_id;
2102
+ const threadId = typeof m.message_thread_id === "number" ? (m.message_thread_id as number) : undefined;
2103
+ if (await this.handleLifecycleCommand(cmdText, updateId, threadId, commandCtx)) return;
2104
+ }
1919
2105
  }
1920
2106
  }
1921
2107
  // Threaded injection: a free-text message in a known topic (not a button
@@ -1940,7 +2126,6 @@ export class TelegramNotificationDaemon {
1940
2126
  });
1941
2127
  if (inbound.kind === "duplicate") return;
1942
2128
  if (inbound.kind === "inject") {
1943
- this.dispatchState.seenUpdateIds.add(inbound.updateId);
1944
2129
  const session = this.sessions.get(inbound.sessionId);
1945
2130
  if (session?.ws.readyState === WebSocket.OPEN) {
1946
2131
  const attachmentResult = inbound.attachment
@@ -1965,6 +2150,7 @@ export class TelegramNotificationDaemon {
1965
2150
  token: session.token,
1966
2151
  }),
1967
2152
  );
2153
+ await this.rememberSeenUpdateId(inbound.updateId);
1968
2154
  if (inbound.messageId !== undefined) await this.setReaction(inbound.messageId, QUEUED_REACTION);
1969
2155
  return;
1970
2156
  }
@@ -1983,6 +2169,7 @@ export class TelegramNotificationDaemon {
1983
2169
  },
1984
2170
  ),
1985
2171
  );
2172
+ await this.rememberSeenUpdateId(inbound.updateId);
1986
2173
  // User turns get a native delivery double-check: queued on receipt,
1987
2174
  // flipped to consumed when the session acks the turn that picks it
1988
2175
  // up. Config commands are not user turns and get no reaction.
@@ -1998,7 +2185,6 @@ export class TelegramNotificationDaemon {
1998
2185
  const decision = routeInboundUpdate(update, {
1999
2186
  aliasTable: this.aliasTable,
2000
2187
  messageRoutes: this.messageRoutes,
2001
- pendingBySession: this.pendingBySession,
2002
2188
  pairedChatId: this.opts.chatId,
2003
2189
  });
2004
2190
  if (decision.kind === "reply") {
@@ -2007,11 +2193,10 @@ export class TelegramNotificationDaemon {
2007
2193
  await this.sendStaleGuidance(callbackId);
2008
2194
  return;
2009
2195
  }
2010
- if (typeof callbackId === "string")
2011
- await this.botApi.call("answerCallbackQuery", { callback_query_id: callbackId });
2012
2196
  session.ws.send(
2013
2197
  JSON.stringify({ type: "reply", id: decision.actionId, answer: decision.answer, token: session.token }),
2014
2198
  );
2199
+ await this.answerCallbackQueryBestEffort(callbackId);
2015
2200
  } else if (decision.kind === "stale") {
2016
2201
  await this.sendStaleGuidance(callbackId);
2017
2202
  }
@@ -2054,9 +2239,11 @@ export class TelegramNotificationDaemon {
2054
2239
  this.startScanTimer();
2055
2240
  this.startTypingTimer();
2056
2241
  try {
2242
+ await this.refreshBotIdentity();
2057
2243
  await this.registerBotCommands();
2058
2244
  await this.loadAliases();
2059
2245
  await this.loadTopics();
2246
+ await this.loadSeenUpdateIds();
2060
2247
  await this.runScan();
2061
2248
  // Owner-only: start the session-lifecycle control server now that
2062
2249
  // ownership is confirmed (singleton-safe). Best-effort; degrades.
@@ -2117,6 +2304,7 @@ export class TelegramNotificationDaemon {
2117
2304
  // (e.g. after reload) reloads aliases/topics seamlessly.
2118
2305
  await this.persistAliases().catch(() => undefined);
2119
2306
  await this.persistTopics().catch(() => undefined);
2307
+ await this.persistSeenUpdateIds().catch(() => undefined);
2120
2308
  await this.opts.control?.clear?.(this.opts.ownerId).catch(() => undefined);
2121
2309
  await releaseDaemonOwnership({
2122
2310
  settings: this.opts.settings,
@@ -197,15 +197,9 @@ export type RouteDecision =
197
197
  | { kind: "stale"; reason: string }
198
198
  | { kind: "ignore" };
199
199
 
200
- export interface PendingAsk {
201
- sessionId: string;
202
- actionId: string;
203
- }
204
-
205
200
  export interface RouteInboundContext {
206
201
  aliasTable: Pick<AliasTable, "get">;
207
202
  messageRoutes: Map<string | number, CallbackRoute | Omit<CallbackRoute, "answer">>;
208
- pendingBySession: (sessionId?: string) => PendingAsk[];
209
203
  pairedChatId: string;
210
204
  }
211
205
 
@@ -250,16 +244,6 @@ export function routeInboundUpdate(update: unknown, ctx: RouteInboundContext): R
250
244
  if (!route) return { kind: "stale", reason: "unknown_reply_message" };
251
245
  return { kind: "reply", ...routeWithAnswer(route, text) };
252
246
  }
253
-
254
- if (text) {
255
- const allPending = ctx.pendingBySession(undefined);
256
- if (allPending.length === 1) {
257
- const [pending] = allPending;
258
- return { kind: "reply", sessionId: pending!.sessionId, actionId: pending!.actionId, answer: text };
259
- }
260
- if (allPending.length > 1) return { kind: "stale", reason: "ambiguous_plain_text" };
261
- }
262
-
263
247
  return { kind: "ignore" };
264
248
  }
265
249
 
@@ -60,9 +60,11 @@ Use for read-only plan critique. It approves only when execution can proceed wit
60
60
 
61
61
  <routing>
62
62
  - Clear, low-risk implementation request → implement directly with focused verification.
63
+ - Informational questions, bare `?`, and unambiguous explanatory prompts are answer-only/read-only: answer from available context and do not modify files, run commands, or execute workflows unless the user explicitly asks to change, run, implement, or execute something.
63
64
  - Do not invoke `deep-interview`, `ralplan`, `ultragoal`, `team`, or role agents for simple clear implementation requests; direct tools and the default launch path are enough.
64
65
  - The runtime records a `workflow-intent-diff` CustomEntry for direct-path traceability; it does not participate in LLM context and is not a reason to slow down direct execution.
65
66
  - When a task is clear, bounded, and low-risk, the default action is to make the smallest correct change and verify it, not to interview, plan, open a durable ledger, or delegate.
67
+ - Ambiguous implementation asks with missing target, scope, acceptance criteria, or safety boundary require clarification or the appropriate planning workflow before mutation.
66
68
  - Small verification needs do not make a task a planning workflow. Escalate only for real ambiguity, non-trivial architecture/sequence risk, durable multi-goal tracking, or useful coordinated workers.
67
69
  - Root-cause phase schema is active only for contradiction, regression, or high-risk transition work; otherwise keep ordinary verification and do not add root-cause ceremony.
68
70
  - Vague requirements → use `deep-interview` before planning or execution.
@@ -98,6 +100,7 @@ Use for read-only plan critique. It approves only when execution can proceed wit
98
100
  - Be concise and information-dense.
99
101
  - Do not narrate progress, ceremony, timing, scope inflation, or session limits.
100
102
  - If the user's intent is clear, act without asking. Ask only when the next step is destructive or requires a missing choice that materially changes the outcome.
103
+ - Treat an informational question as a request for an answer, not implicit permission to take action; answer read-only unless the user explicitly asks for a concrete change or command execution.
101
104
  - When the user proposes something wrong, say what breaks and what to do instead once; then defer to their call.
102
105
  - Never use permission-begging or deferral phrasing ("if you want", "if you'd like", "shall I", "I will now", "next I plan to"). For a destructive next step, state the recommended action and stop for approval. For a non-destructive, clearly correct next step, do it directly in the same turn.
103
106
  - Do not defer actionable work. Underpromise and overdeliver: report only what is done or in progress, never announce remaining work instead of doing it.