@gajae-code/coding-agent 0.8.1 → 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 (112) hide show
  1. package/CHANGELOG.md +34 -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 +15 -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/workflow/workflow-intent-diff.d.ts +8 -2
  40. package/dist/types/workflow/workflow-intent-report-consumer.d.ts +50 -0
  41. package/dist/types/workflow/workflow-intent-report-schema.d.ts +2 -0
  42. package/dist/types/workflow/workflow-intent-report.d.ts +48 -0
  43. package/package.json +7 -7
  44. package/src/cli/completion-cli.ts +234 -0
  45. package/src/cli/config-cli.ts +37 -9
  46. package/src/cli/fast-help.ts +1 -0
  47. package/src/cli/notify-cli.ts +91 -7
  48. package/src/cli.ts +3 -1
  49. package/src/commands/completion.ts +84 -0
  50. package/src/commands/config.ts +2 -0
  51. package/src/commands/deep-interview.ts +2 -1
  52. package/src/config/file-lock.ts +73 -34
  53. package/src/config/keybindings.ts +2 -2
  54. package/src/config/model-profile-activation.ts +65 -0
  55. package/src/config/model-registry.ts +14 -2
  56. package/src/config/settings.ts +22 -0
  57. package/src/coordinator-mcp/server.ts +34 -8
  58. package/src/defaults/gjc/skills/deep-interview/SKILL.md +38 -5
  59. package/src/defaults/gjc/skills/ralplan/SKILL.md +15 -11
  60. package/src/defaults/gjc/skills/team/SKILL.md +1 -1
  61. package/src/extensibility/extensions/runner.ts +2 -0
  62. package/src/extensibility/extensions/types.ts +9 -0
  63. package/src/gjc-runtime/deep-interview-ambiguity.ts +206 -0
  64. package/src/gjc-runtime/deep-interview-recorder.ts +62 -8
  65. package/src/gjc-runtime/deep-interview-runtime.ts +210 -2
  66. package/src/gjc-runtime/deep-interview-state.ts +13 -0
  67. package/src/gjc-runtime/session-state-sidecar.ts +59 -11
  68. package/src/gjc-runtime/state-runtime.ts +12 -2
  69. package/src/gjc-runtime/team-runtime.ts +23 -0
  70. package/src/gjc-runtime/ultragoal-runtime.ts +73 -19
  71. package/src/hooks/native-skill-hook.ts +29 -0
  72. package/src/internal-urls/docs-index.generated.ts +2 -2
  73. package/src/main.ts +52 -41
  74. package/src/modes/components/model-selector.ts +54 -11
  75. package/src/modes/components/queued-message-selector.ts +92 -0
  76. package/src/modes/components/settings-selector.ts +4 -0
  77. package/src/modes/components/welcome.ts +160 -44
  78. package/src/modes/controllers/command-controller.ts +1 -2
  79. package/src/modes/controllers/extension-ui-controller.ts +4 -10
  80. package/src/modes/controllers/input-controller.ts +179 -17
  81. package/src/modes/controllers/selector-controller.ts +122 -27
  82. package/src/modes/interactive-mode.ts +43 -16
  83. package/src/modes/print-mode.ts +43 -4
  84. package/src/modes/prompt-action-autocomplete.ts +21 -20
  85. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  86. package/src/modes/utils/ui-helpers.ts +2 -2
  87. package/src/notifications/config.ts +9 -1
  88. package/src/notifications/daemon-paths.ts +2 -0
  89. package/src/notifications/index.ts +39 -9
  90. package/src/notifications/lifecycle-commands.ts +37 -7
  91. package/src/notifications/lifecycle-orchestrator.ts +19 -6
  92. package/src/notifications/telegram-daemon-control.ts +3 -1
  93. package/src/notifications/telegram-daemon.ts +217 -43
  94. package/src/notifications/telegram-reference.ts +0 -16
  95. package/src/prompts/system/system-prompt.md +3 -0
  96. package/src/prompts/tools/computer.md +2 -2
  97. package/src/runtime-api-key.ts +16 -0
  98. package/src/sdk.ts +20 -6
  99. package/src/session/agent-session.ts +225 -31
  100. package/src/session/agent-storage.ts +4 -0
  101. package/src/session/session-manager.ts +44 -2
  102. package/src/slash-commands/builtin-registry.ts +153 -71
  103. package/src/task/executor.ts +7 -4
  104. package/src/task/index.ts +9 -3
  105. package/src/task/output-manager.ts +16 -6
  106. package/src/task/receipt.ts +26 -7
  107. package/src/tools/computer.ts +246 -43
  108. package/src/tools/telegram-send.ts +6 -0
  109. package/src/workflow/workflow-intent-diff.ts +29 -6
  110. package/src/workflow/workflow-intent-report-consumer.ts +221 -0
  111. package/src/workflow/workflow-intent-report-schema.ts +129 -0
  112. 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[] {
@@ -267,7 +268,7 @@ export async function registerNotificationRoot(input: {
267
268
  const fsImpl = input.fs ?? nodeFs;
268
269
  const paths = daemonPaths(input.settings.getAgentDir());
269
270
  await ensureDir(fsImpl, paths.dir);
270
- const root = path.join(input.cwd, ".gjc", "state");
271
+ const root = notificationRootForCwd(input.cwd);
271
272
  await withFileLock(
272
273
  paths.roots,
273
274
  async () => {
@@ -286,6 +287,29 @@ export async function registerNotificationRoot(input: {
286
287
  return root;
287
288
  }
288
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
+
289
313
  export function isFreshLiveOwner(input: {
290
314
  state: DaemonState | undefined;
291
315
  now: number;
@@ -297,8 +321,7 @@ export function isFreshLiveOwner(input: {
297
321
  return Boolean(
298
322
  state &&
299
323
  state.version === DAEMON_VERSION &&
300
- state.tokenFingerprint === input.tokenFingerprint &&
301
- state.chatId === input.chatId &&
324
+ ownerIdentityMatches(state, input.tokenFingerprint, input.chatId) &&
302
325
  input.now - state.heartbeatAt <= HEARTBEAT_TTL_MS &&
303
326
  input.pidAlive(state.pid),
304
327
  );
@@ -314,7 +337,13 @@ export async function acquireDaemonOwnership(input: {
314
337
  pid?: number;
315
338
  pidAlive?: (pid: number) => boolean;
316
339
  randomId?: () => string;
317
- }): 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
+ }> {
318
347
  const fsImpl = input.fs ?? nodeFs;
319
348
  const now = input.now ?? Date.now;
320
349
  const pid = input.pid ?? process.pid;
@@ -324,6 +353,16 @@ export async function acquireDaemonOwnership(input: {
324
353
  const ownerId = input.randomId?.() ?? `${pid}-${now().toString(36)}-${Math.random().toString(36).slice(2)}`;
325
354
  const roots = input.roots ?? (await readJson<{ roots?: string[] }>(fsImpl, paths.roots))?.roots ?? [];
326
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
+ }
327
366
  if (
328
367
  isFreshLiveOwner({
329
368
  state: existing,
@@ -349,6 +388,16 @@ export async function acquireDaemonOwnership(input: {
349
388
  return { acquired: true, ownerId };
350
389
  }
351
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
+ }
352
401
  if (
353
402
  isFreshLiveOwner({
354
403
  state: afterLock,
@@ -375,6 +424,16 @@ export async function acquireDaemonOwnership(input: {
375
424
  ) {
376
425
  return { acquired: false, attached: true };
377
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
+ }
378
437
  if (rechecked && pidAlive(rechecked.pid)) {
379
438
  return { acquired: false, attached: true };
380
439
  }
@@ -552,7 +611,16 @@ export async function spawnTelegramDaemonOwner(
552
611
  ownerId: ownership.ownerId ?? "",
553
612
  agentDir,
554
613
  });
555
- 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
+ }
556
624
  const spawnImpl = deps.spawn ?? defaultDaemonSpawn;
557
625
  const child = spawnImpl(command, args, {
558
626
  detached: true,
@@ -569,12 +637,17 @@ export async function ensureTelegramDaemonRunning(
569
637
  ): Promise<EnsureDaemonResult> {
570
638
  const cfg = getNotificationConfig(input.settings);
571
639
  if (!isGloballyConfigured(cfg) || !cfg.botToken || !cfg.chatId) return "disabled";
572
- const root = await registerNotificationRoot({ ...input, fs: deps.fs });
640
+ const root = notificationRootForCwd(input.cwd);
573
641
  const fp = tokenFingerprint(cfg.botToken);
574
642
  const spawned = await spawnTelegramDaemonOwner(
575
643
  { settings: input.settings, roots: [root], tokenFingerprint: fp, chatId: cfg.chatId },
576
644
  deps,
577
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 });
578
651
  return spawned.result;
579
652
  }
580
653
 
@@ -820,6 +893,8 @@ export class TelegramNotificationDaemon {
820
893
  private readonly flatIdentitySent = new Set<string>();
821
894
  /** Cached result of whether the paired chat is a private chat (flat-fallback gate). */
822
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;
823
898
  /** Sessions whose agent loop is currently busy (drives the typing indicator). */
824
899
  private get busy(): Set<string> {
825
900
  return this.dispatchState.busy;
@@ -1078,8 +1153,10 @@ export class TelegramNotificationDaemon {
1078
1153
  text: string | undefined,
1079
1154
  updateId: number | undefined,
1080
1155
  threadId: number | undefined,
1156
+ commandCtx: { chatType?: string; botUsername?: string },
1081
1157
  ): Promise<boolean> {
1082
- if (!isLifecycleCommandText(text)) return false;
1158
+ if (!isLifecycleCommandText(text, commandCtx)) return false;
1159
+ if (!(await this.pairedChatIsPrivate())) return true;
1083
1160
  const reply = async (body: string): Promise<void> => {
1084
1161
  for (const text of splitTelegramPlainText(body)) {
1085
1162
  await this.botApi
@@ -1104,15 +1181,15 @@ export class TelegramNotificationDaemon {
1104
1181
  }
1105
1182
  };
1106
1183
 
1184
+ const parsed = parseLifecycleCommand(text, commandCtx);
1185
+ if (parsed.kind === "none") return false;
1107
1186
  if (!this.lifecycleControlActive) {
1108
1187
  await reply("Session lifecycle control is not available right now.");
1109
1188
  return true;
1110
1189
  }
1111
1190
  if (updateId !== undefined && this.dispatchState.seenUpdateIds.has(updateId)) return true;
1112
- if (updateId !== undefined) this.dispatchState.seenUpdateIds.add(updateId);
1191
+ if (updateId !== undefined) await this.rememberSeenUpdateId(updateId);
1113
1192
 
1114
- const parsed = parseLifecycleCommand(text);
1115
- if (parsed.kind === "none") return false;
1116
1193
  if (parsed.kind === "usage" || parsed.kind === "reject") {
1117
1194
  await reply(parsed.message);
1118
1195
  return true;
@@ -1145,6 +1222,17 @@ export class TelegramNotificationDaemon {
1145
1222
  return true;
1146
1223
  }
1147
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
+
1148
1236
  /** Map a lifecycle response/error to a user-facing message (G010 surfacing). */
1149
1237
  private formatLifecycleResponse(r: SessionLifecycleResponse): string {
1150
1238
  return formatLifecycleOutcome(r);
@@ -1247,6 +1335,51 @@ export class TelegramNotificationDaemon {
1247
1335
  await writeJsonAtomic(this.fsImpl, paths.aliases, this.aliasTable.serialize());
1248
1336
  }
1249
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
+
1250
1383
  async scanRoots(): Promise<void> {
1251
1384
  const paths = daemonPaths(this.opts.settings.getAgentDir());
1252
1385
  const rootState = await readJson<{ roots?: string[] }>(this.fsImpl, paths.roots);
@@ -1366,13 +1499,17 @@ export class TelegramNotificationDaemon {
1366
1499
  * (so a delayed old close cannot delete a replacement), and best-effort closes
1367
1500
  * the socket. `scanRoots()` then reconnects the session.
1368
1501
  */
1369
- private dropSession(session: SessionSocket, _reason: string): void {
1502
+ private dropSession(session: SessionSocket, reason: string): void {
1370
1503
  const clearIntervalImpl = this.opts.clearIntervalImpl ?? clearInterval;
1371
1504
  if (session.pingTimer) {
1372
1505
  clearIntervalImpl(session.pingTimer);
1373
1506
  session.pingTimer = undefined;
1374
1507
  }
1375
- 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) {
1376
1513
  this.sessions.delete(session.sessionId);
1377
1514
  }
1378
1515
  if (session.ws.readyState !== WebSocket.CLOSED) {
@@ -1382,6 +1519,14 @@ export class TelegramNotificationDaemon {
1382
1519
  }
1383
1520
  }
1384
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
+
1385
1530
  private static readonly THREADED_FRAMES = new Set([
1386
1531
  "identity_header",
1387
1532
  "context_update",
@@ -1434,6 +1579,13 @@ export class TelegramNotificationDaemon {
1434
1579
  return undefined;
1435
1580
  }
1436
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
+
1437
1589
  private async submitThreadedFrame(sessionId: string, send: ThreadedSend, topicId: string): Promise<void> {
1438
1590
  this.pool.submit({
1439
1591
  sessionId,
@@ -1444,6 +1596,11 @@ export class TelegramNotificationDaemon {
1444
1596
  await this.flushPool();
1445
1597
  }
1446
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
+
1447
1604
  private rememberPendingThreadedFrame(sessionId: string, send: ThreadedSend, msg: Record<string, unknown>): void {
1448
1605
  const frames = this.pendingThreadedFrames.get(sessionId) ?? [];
1449
1606
  frames.push({ send, msg });
@@ -1465,6 +1622,7 @@ export class TelegramNotificationDaemon {
1465
1622
  * one-time nudge) or drop fail-closed for a non-private chat.
1466
1623
  */
1467
1624
  private async ensureTopic(sessionId: string, name: string): Promise<string | undefined> {
1625
+ if (!(await this.pairedChatIsPrivate())) return undefined;
1468
1626
  const existing = this.topics.get(sessionId);
1469
1627
  if (existing) return existing.topicId;
1470
1628
  try {
@@ -1639,6 +1797,7 @@ export class TelegramNotificationDaemon {
1639
1797
  private async flushPool(): Promise<void> {
1640
1798
  for (const item of this.pool.drain()) {
1641
1799
  const { send, topicId } = item.payload;
1800
+ if (topicId && !(await this.pairedChatIsPrivate())) continue;
1642
1801
  // Threaded topic when available; otherwise deliver flat to the paired chat.
1643
1802
  const threadField = topicId ? { message_thread_id: Number(topicId) } : {};
1644
1803
  try {
@@ -1697,9 +1856,10 @@ export class TelegramNotificationDaemon {
1697
1856
  }
1698
1857
 
1699
1858
  /**
1700
- * Resolve once (cached) whether the paired `chatId` is a private chat. Flat
1701
- * fallback is only safe in a private DM; any non-private chat or an unresolvable
1702
- * `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.
1703
1863
  */
1704
1864
  private async pairedChatIsPrivate(): Promise<boolean> {
1705
1865
  if (this.pairedChatPrivate !== undefined) return this.pairedChatPrivate;
@@ -1708,15 +1868,16 @@ export class TelegramNotificationDaemon {
1708
1868
  result?: { type?: string };
1709
1869
  };
1710
1870
  this.pairedChatPrivate = res.result?.type === "private";
1711
- } catch {
1712
- 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;
1713
1875
  }
1714
- return this.pairedChatPrivate;
1715
1876
  }
1716
1877
 
1717
1878
  /** Tell the user once (per daemon run) how to enable Threaded Mode. */
1718
1879
  private async notifyThreadedFallback(): Promise<void> {
1719
- if (this.threadedFallbackNoticeSent) return;
1880
+ if (this.threadedFallbackNoticeSent || !(await this.pairedChatIsPrivate())) return;
1720
1881
  this.threadedFallbackNoticeSent = true;
1721
1882
  try {
1722
1883
  await this.botApi.call("sendMessage", {
@@ -1761,7 +1922,7 @@ export class TelegramNotificationDaemon {
1761
1922
  /** Send a single `typing` chat action into a busy session's topic (best-effort). */
1762
1923
  private async sendTyping(sessionId: string): Promise<void> {
1763
1924
  const topicId = this.topics.get(sessionId)?.topicId;
1764
- if (!topicId) return;
1925
+ if (!topicId || !(await this.pairedChatIsPrivate())) return;
1765
1926
  try {
1766
1927
  await this.botApi.call("sendChatAction", {
1767
1928
  chat_id: this.opts.chatId,
@@ -1775,6 +1936,7 @@ export class TelegramNotificationDaemon {
1775
1936
 
1776
1937
  /** Set a native reaction on an inbound thread message (best-effort). */
1777
1938
  private async setReaction(messageId: number, emoji: string): Promise<void> {
1939
+ if (!(await this.pairedChatIsPrivate())) return;
1778
1940
  try {
1779
1941
  await this.botApi.call("setMessageReaction", {
1780
1942
  chat_id: this.opts.chatId,
@@ -1802,12 +1964,12 @@ export class TelegramNotificationDaemon {
1802
1964
  if (typeof msg?.type === "string" && TelegramNotificationDaemon.THREADED_FRAMES.has(msg.type)) {
1803
1965
  const send = renderThreadedFrame(msg);
1804
1966
  if (!send) return;
1805
- const existingTopic = this.topics.get(session.sessionId)?.topicId;
1967
+ const existingTopic = await this.existingTopicForPrivateChat(session.sessionId);
1806
1968
  if (!send.identity && !existingTopic && !this.flatIdentitySent.has(session.sessionId)) {
1807
1969
  this.rememberPendingThreadedFrame(session.sessionId, send, msg as Record<string, unknown>);
1808
1970
  return;
1809
1971
  }
1810
- if (send.identity) {
1972
+ if (send.identity && !this.sessionCanClaimIdentity(session, msg)) {
1811
1973
  const ownerId = this.topicOwnerForIdentity(msg);
1812
1974
  const ownerTopic = ownerId ? this.topics.get(ownerId) : undefined;
1813
1975
  if (ownerId && ownerId !== session.sessionId && ownerTopic) {
@@ -1890,6 +2052,7 @@ export class TelegramNotificationDaemon {
1890
2052
  await this.persistAliases();
1891
2053
  } else if (msg.type === "action_resolved" && msg.id) {
1892
2054
  session.pending.delete(msg.id);
2055
+ this.deleteMessageRoutes(session.sessionId, msg.id);
1893
2056
  for (const [alias, route] of this.aliasTable.entries()) {
1894
2057
  if (route.sessionId === session.sessionId && route.actionId === msg.id) this.aliasTable.delete(alias);
1895
2058
  }
@@ -1897,19 +2060,22 @@ export class TelegramNotificationDaemon {
1897
2060
  }
1898
2061
  }
1899
2062
 
1900
- pendingBySession = (sessionId?: string): PendingAsk[] => {
1901
- const result: PendingAsk[] = [];
1902
- for (const session of this.sessions.values()) {
1903
- if (sessionId && session.sessionId !== sessionId) continue;
1904
- 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.
1905
2073
  }
1906
- return result;
1907
- };
2074
+ }
1908
2075
 
1909
2076
  private async sendStaleGuidance(callbackId: unknown): Promise<void> {
1910
- if (typeof callbackId === "string") {
1911
- await this.botApi.call("answerCallbackQuery", { callback_query_id: callbackId, text: "Button is stale" });
1912
- }
2077
+ await this.answerCallbackQueryBestEffort(callbackId, "Button is stale");
2078
+ if (!(await this.pairedChatIsPrivate())) return;
1913
2079
  await this.botApi.call("sendMessage", {
1914
2080
  chat_id: this.opts.chatId,
1915
2081
  text: "This button is stale after notification daemon restart. Please answer locally in the GJC session or wait for a fresh notification.",
@@ -1924,12 +2090,18 @@ export class TelegramNotificationDaemon {
1924
2090
  // session input.
1925
2091
  {
1926
2092
  const m = (update as { update_id?: number; message?: Record<string, unknown> }).message;
1927
- 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;
1928
2096
  const cmdText = typeof m?.text === "string" ? m.text : undefined;
1929
- if (m !== undefined && String(chatId) === String(this.opts.chatId) && isLifecycleCommandText(cmdText)) {
1930
- const updateId = (update as { update_id?: number }).update_id;
1931
- const threadId = typeof m.message_thread_id === "number" ? (m.message_thread_id as number) : undefined;
1932
- 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
+ }
1933
2105
  }
1934
2106
  }
1935
2107
  // Threaded injection: a free-text message in a known topic (not a button
@@ -1954,7 +2126,6 @@ export class TelegramNotificationDaemon {
1954
2126
  });
1955
2127
  if (inbound.kind === "duplicate") return;
1956
2128
  if (inbound.kind === "inject") {
1957
- this.dispatchState.seenUpdateIds.add(inbound.updateId);
1958
2129
  const session = this.sessions.get(inbound.sessionId);
1959
2130
  if (session?.ws.readyState === WebSocket.OPEN) {
1960
2131
  const attachmentResult = inbound.attachment
@@ -1979,6 +2150,7 @@ export class TelegramNotificationDaemon {
1979
2150
  token: session.token,
1980
2151
  }),
1981
2152
  );
2153
+ await this.rememberSeenUpdateId(inbound.updateId);
1982
2154
  if (inbound.messageId !== undefined) await this.setReaction(inbound.messageId, QUEUED_REACTION);
1983
2155
  return;
1984
2156
  }
@@ -1997,6 +2169,7 @@ export class TelegramNotificationDaemon {
1997
2169
  },
1998
2170
  ),
1999
2171
  );
2172
+ await this.rememberSeenUpdateId(inbound.updateId);
2000
2173
  // User turns get a native delivery double-check: queued on receipt,
2001
2174
  // flipped to consumed when the session acks the turn that picks it
2002
2175
  // up. Config commands are not user turns and get no reaction.
@@ -2012,7 +2185,6 @@ export class TelegramNotificationDaemon {
2012
2185
  const decision = routeInboundUpdate(update, {
2013
2186
  aliasTable: this.aliasTable,
2014
2187
  messageRoutes: this.messageRoutes,
2015
- pendingBySession: this.pendingBySession,
2016
2188
  pairedChatId: this.opts.chatId,
2017
2189
  });
2018
2190
  if (decision.kind === "reply") {
@@ -2021,11 +2193,10 @@ export class TelegramNotificationDaemon {
2021
2193
  await this.sendStaleGuidance(callbackId);
2022
2194
  return;
2023
2195
  }
2024
- if (typeof callbackId === "string")
2025
- await this.botApi.call("answerCallbackQuery", { callback_query_id: callbackId });
2026
2196
  session.ws.send(
2027
2197
  JSON.stringify({ type: "reply", id: decision.actionId, answer: decision.answer, token: session.token }),
2028
2198
  );
2199
+ await this.answerCallbackQueryBestEffort(callbackId);
2029
2200
  } else if (decision.kind === "stale") {
2030
2201
  await this.sendStaleGuidance(callbackId);
2031
2202
  }
@@ -2068,9 +2239,11 @@ export class TelegramNotificationDaemon {
2068
2239
  this.startScanTimer();
2069
2240
  this.startTypingTimer();
2070
2241
  try {
2242
+ await this.refreshBotIdentity();
2071
2243
  await this.registerBotCommands();
2072
2244
  await this.loadAliases();
2073
2245
  await this.loadTopics();
2246
+ await this.loadSeenUpdateIds();
2074
2247
  await this.runScan();
2075
2248
  // Owner-only: start the session-lifecycle control server now that
2076
2249
  // ownership is confirmed (singleton-safe). Best-effort; degrades.
@@ -2131,6 +2304,7 @@ export class TelegramNotificationDaemon {
2131
2304
  // (e.g. after reload) reloads aliases/topics seamlessly.
2132
2305
  await this.persistAliases().catch(() => undefined);
2133
2306
  await this.persistTopics().catch(() => undefined);
2307
+ await this.persistSeenUpdateIds().catch(() => undefined);
2134
2308
  await this.opts.control?.clear?.(this.opts.ownerId).catch(() => undefined);
2135
2309
  await releaseDaemonOwnership({
2136
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.
@@ -6,7 +6,7 @@
6
6
 
7
7
  - Disabled means disabled: when the tool is disabled (`computer.alwaysOn=false` with `computer.enabled` unset/false) or the platform is unsupported, every action including `screenshot` fails with `COMPUTER_DISABLED` and captures nothing.
8
8
  - Callable only on Apple Silicon macOS (`arm64` darwin); available by default there, with `computer.alwaysOn=false` as the off-switch and `computer.enabled=true` as the manual enable path.
9
- - Native execution remains supervisor-gated. If the stop/suspend supervisor is unavailable, stale, suspended, permissioned off, display-stale, or cancelled, the action fails closed with a `COMPUTER_*` code.
9
+ - Native execution remains supervisor-gated. If the stop/suspend supervisor is unavailable, stale, suspended, permissioned off, display-stale, or cancelled, the action fails closed with a `COMPUTER_*` code. Coordinate actions carry the latest known screenshot display epoch when one is available so display-topology changes fail with `COMPUTER_DISPLAY_STALE`.
10
10
  - Respect the user's stop/suspend request immediately. Do not loop desktop actions after a stop/suspend/error.
11
11
  - The user can stop or suspend the session at any time with the configured kill-switch hotkey (default `Control+Option+Command+Escape`). If you see `COMPUTER_CANCELLED` or `COMPUTER_SUPERVISOR_NOT_LIVE`, stop and wait for the user.
12
12
 
@@ -14,7 +14,7 @@
14
14
 
15
15
  Coordinates are screenshot pixels, not CSS pixels and not normalized fractions. Use the latest successful `screenshot` dimensions and origin/scale metadata as the coordinate frame. Do not guess coordinates outside the screenshot bounds.
16
16
 
17
- When you send a sequence of actions in one `batch` call, pointer coordinates in later steps are validated against the most recent screenshot from that batch. If a coordinate is out of bounds, the batch stops and reports `COMPUTER_COORD_INVALID`. Always capture a fresh screenshot before acting if the display may have changed.
17
+ For stale-display protection to apply, derive pointer coordinates from a successful screenshot in the same tool session; a screenshot-first `batch` is preferred because later coordinate steps are validated against that screenshot and carry its display epoch into native execution. If a coordinate is out of bounds, the batch stops and reports `COMPUTER_COORD_INVALID`. Always capture a fresh screenshot before acting if the display may have changed.
18
18
 
19
19
  ## Actions
20
20
 
@@ -0,0 +1,16 @@
1
+ export interface RuntimeApiKeyTarget {
2
+ setRuntimeApiKey(provider: string, apiKey: string): void;
3
+ }
4
+
5
+ export interface RuntimeApiKeyModel {
6
+ provider: string;
7
+ }
8
+
9
+ export function applyCliRuntimeApiKeyOverride(
10
+ target: RuntimeApiKeyTarget,
11
+ apiKey: string | undefined,
12
+ model: RuntimeApiKeyModel | undefined,
13
+ ): void {
14
+ if (!apiKey || !model) return;
15
+ target.setRuntimeApiKey(model.provider, apiKey);
16
+ }