@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.
- package/CHANGELOG.md +48 -0
- package/dist/types/cli/completion-cli.d.ts +41 -0
- package/dist/types/cli/config-cli.d.ts +1 -0
- package/dist/types/cli/notify-cli.d.ts +7 -0
- package/dist/types/cli.d.ts +124 -1
- package/dist/types/commands/completion.d.ts +29 -0
- package/dist/types/commands/config.d.ts +3 -0
- package/dist/types/commands/deep-interview.d.ts +3 -0
- package/dist/types/config/file-lock.d.ts +7 -6
- package/dist/types/config/keybindings.d.ts +2 -2
- package/dist/types/config/model-profile-activation.d.ts +6 -0
- package/dist/types/config/settings.d.ts +8 -0
- package/dist/types/extensibility/extensions/runner.d.ts +2 -1
- package/dist/types/extensibility/extensions/types.d.ts +8 -0
- package/dist/types/gjc-runtime/deep-interview-ambiguity.d.ts +39 -0
- package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +10 -1
- package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +1 -1
- package/dist/types/gjc-runtime/deep-interview-state.d.ts +12 -0
- package/dist/types/gjc-runtime/team-runtime.d.ts +2 -1
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +1 -0
- package/dist/types/main.d.ts +4 -1
- package/dist/types/modes/components/model-selector.d.ts +4 -0
- package/dist/types/modes/components/queued-message-selector.d.ts +12 -0
- package/dist/types/modes/components/settings-selector.d.ts +2 -0
- package/dist/types/modes/controllers/input-controller.d.ts +1 -3
- package/dist/types/modes/print-mode.d.ts +13 -1
- package/dist/types/modes/prompt-action-autocomplete.d.ts +1 -1
- package/dist/types/notifications/config.d.ts +8 -1
- package/dist/types/notifications/daemon-paths.d.ts +1 -0
- package/dist/types/notifications/index.d.ts +6 -3
- package/dist/types/notifications/lifecycle-commands.d.ts +11 -3
- package/dist/types/notifications/telegram-daemon.d.ts +18 -3
- package/dist/types/notifications/telegram-reference.d.ts +0 -5
- package/dist/types/runtime-api-key.d.ts +7 -0
- package/dist/types/session/agent-session.d.ts +10 -0
- package/dist/types/session/agent-storage.d.ts +1 -0
- package/dist/types/task/executor.d.ts +2 -0
- package/dist/types/tools/computer.d.ts +2 -2
- package/dist/types/tools/skill.d.ts +1 -1
- package/dist/types/workflow/workflow-intent-diff.d.ts +8 -2
- package/dist/types/workflow/workflow-intent-report-consumer.d.ts +50 -0
- package/dist/types/workflow/workflow-intent-report-schema.d.ts +2 -0
- package/dist/types/workflow/workflow-intent-report.d.ts +48 -0
- package/package.json +7 -7
- package/src/cli/completion-cli.ts +234 -0
- package/src/cli/config-cli.ts +37 -9
- package/src/cli/fast-help.ts +1 -0
- package/src/cli/notify-cli.ts +91 -7
- package/src/cli.ts +3 -1
- package/src/commands/completion.ts +84 -0
- package/src/commands/config.ts +2 -0
- package/src/commands/deep-interview.ts +2 -1
- package/src/config/file-lock.ts +73 -34
- package/src/config/keybindings.ts +2 -2
- package/src/config/model-profile-activation.ts +65 -0
- package/src/config/model-registry.ts +14 -2
- package/src/config/settings.ts +22 -0
- package/src/coordinator-mcp/server.ts +67 -12
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +38 -5
- package/src/defaults/gjc/skills/ralplan/SKILL.md +15 -11
- package/src/defaults/gjc/skills/team/SKILL.md +1 -1
- package/src/extensibility/extensions/runner.ts +2 -0
- package/src/extensibility/extensions/types.ts +9 -0
- package/src/extensibility/skills.ts +30 -2
- package/src/gjc-runtime/deep-interview-ambiguity.ts +206 -0
- package/src/gjc-runtime/deep-interview-recorder.ts +62 -8
- package/src/gjc-runtime/deep-interview-runtime.ts +210 -2
- package/src/gjc-runtime/deep-interview-state.ts +13 -0
- package/src/gjc-runtime/session-state-sidecar.ts +59 -11
- package/src/gjc-runtime/state-runtime.ts +12 -2
- package/src/gjc-runtime/team-runtime.ts +23 -0
- package/src/gjc-runtime/ultragoal-runtime.ts +73 -19
- package/src/hooks/native-skill-hook.ts +29 -0
- package/src/internal-urls/docs-index.generated.ts +2 -2
- package/src/main.ts +52 -41
- package/src/modes/acp/acp-agent.ts +7 -6
- package/src/modes/components/model-selector.ts +54 -11
- package/src/modes/components/queued-message-selector.ts +92 -0
- package/src/modes/components/settings-selector.ts +4 -0
- package/src/modes/components/welcome.ts +160 -44
- package/src/modes/controllers/command-controller.ts +4 -4
- package/src/modes/controllers/extension-ui-controller.ts +4 -10
- package/src/modes/controllers/input-controller.ts +186 -25
- package/src/modes/controllers/selector-controller.ts +122 -27
- package/src/modes/interactive-mode.ts +43 -16
- package/src/modes/print-mode.ts +43 -4
- package/src/modes/prompt-action-autocomplete.ts +24 -13
- package/src/modes/utils/hotkeys-markdown.ts +1 -0
- package/src/modes/utils/ui-helpers.ts +2 -2
- package/src/notifications/config.ts +9 -1
- package/src/notifications/daemon-paths.ts +2 -0
- package/src/notifications/index.ts +39 -9
- package/src/notifications/lifecycle-commands.ts +37 -7
- package/src/notifications/lifecycle-orchestrator.ts +19 -6
- package/src/notifications/telegram-daemon-control.ts +3 -1
- package/src/notifications/telegram-daemon.ts +231 -43
- package/src/notifications/telegram-reference.ts +0 -16
- package/src/prompts/system/system-prompt.md +3 -0
- package/src/prompts/tools/computer.md +2 -2
- package/src/runtime-api-key.ts +16 -0
- package/src/sdk.ts +20 -6
- package/src/session/agent-session.ts +225 -31
- package/src/session/agent-storage.ts +4 -0
- package/src/session/session-manager.ts +44 -2
- package/src/slash-commands/builtin-registry.ts +153 -71
- package/src/task/executor.ts +7 -4
- package/src/task/index.ts +9 -3
- package/src/task/output-manager.ts +16 -6
- package/src/task/receipt.ts +26 -7
- package/src/tools/computer.ts +246 -43
- package/src/tools/skill.ts +1 -1
- package/src/tools/telegram-send.ts +6 -0
- package/src/workflow/workflow-intent-diff.ts +29 -6
- package/src/workflow/workflow-intent-report-consumer.ts +221 -0
- package/src/workflow/workflow-intent-report-schema.ts +129 -0
- 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 =
|
|
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
|
|
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<{
|
|
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)
|
|
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 =
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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
|
|
1687
|
-
*
|
|
1688
|
-
*
|
|
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
|
-
|
|
1698
|
-
|
|
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.
|
|
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
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
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
|
-
|
|
1893
|
-
};
|
|
2074
|
+
}
|
|
1894
2075
|
|
|
1895
2076
|
private async sendStaleGuidance(callbackId: unknown): Promise<void> {
|
|
1896
|
-
|
|
1897
|
-
|
|
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
|
|
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
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
if (
|
|
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.
|