@songsid/agend 2.1.11-beta.1 → 2.1.11-beta.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 (70) hide show
  1. package/dist/backend/kiro-engine-ledger.d.ts +36 -0
  2. package/dist/backend/kiro-engine-ledger.js +113 -0
  3. package/dist/backend/kiro-engine-ledger.js.map +1 -0
  4. package/dist/backend/kiro-kas-store.d.ts +11 -0
  5. package/dist/backend/kiro-kas-store.js +67 -0
  6. package/dist/backend/kiro-kas-store.js.map +1 -0
  7. package/dist/backend/kiro-v3-identity.d.ts +28 -0
  8. package/dist/backend/kiro-v3-identity.js +213 -0
  9. package/dist/backend/kiro-v3-identity.js.map +1 -0
  10. package/dist/backend/kiro.d.ts +7 -0
  11. package/dist/backend/kiro.js +34 -1
  12. package/dist/backend/kiro.js.map +1 -1
  13. package/dist/backend/opencode-permission.d.ts +49 -0
  14. package/dist/backend/opencode-permission.js +181 -0
  15. package/dist/backend/opencode-permission.js.map +1 -0
  16. package/dist/backend/opencode.d.ts +6 -0
  17. package/dist/backend/opencode.js +46 -2
  18. package/dist/backend/opencode.js.map +1 -1
  19. package/dist/backend/types.d.ts +15 -0
  20. package/dist/backend/types.js.map +1 -1
  21. package/dist/channel/adapters/discord.js +26 -25
  22. package/dist/channel/adapters/discord.js.map +1 -1
  23. package/dist/channel/ipc-bridge.d.ts +1 -0
  24. package/dist/channel/ipc-bridge.js +1 -1
  25. package/dist/channel/ipc-bridge.js.map +1 -1
  26. package/dist/channel/mcp-tools.js +2 -0
  27. package/dist/channel/mcp-tools.js.map +1 -1
  28. package/dist/command-table.d.ts +99 -0
  29. package/dist/command-table.js +125 -0
  30. package/dist/command-table.js.map +1 -0
  31. package/dist/daemon.d.ts +36 -0
  32. package/dist/daemon.js +165 -2
  33. package/dist/daemon.js.map +1 -1
  34. package/dist/delivery-outbox.d.ts +16 -0
  35. package/dist/delivery-outbox.js +55 -6
  36. package/dist/delivery-outbox.js.map +1 -1
  37. package/dist/e2e-isolation.d.ts +122 -0
  38. package/dist/e2e-isolation.js +386 -0
  39. package/dist/e2e-isolation.js.map +1 -0
  40. package/dist/fleet-manager.d.ts +24 -0
  41. package/dist/fleet-manager.js +292 -484
  42. package/dist/fleet-manager.js.map +1 -1
  43. package/dist/general-knowledge/skills/kiro-engine-migration/SKILL.md +56 -0
  44. package/dist/instance-lifecycle.d.ts +2 -0
  45. package/dist/instance-lifecycle.js +14 -0
  46. package/dist/instance-lifecycle.js.map +1 -1
  47. package/dist/kiro-engine-status.d.ts +71 -0
  48. package/dist/kiro-engine-status.js +68 -0
  49. package/dist/kiro-engine-status.js.map +1 -0
  50. package/dist/locale.js +10 -0
  51. package/dist/locale.js.map +1 -1
  52. package/dist/login-flows.js +10 -0
  53. package/dist/login-flows.js.map +1 -1
  54. package/dist/outbound-handlers.js +16 -1
  55. package/dist/outbound-handlers.js.map +1 -1
  56. package/dist/outbound-schemas.d.ts +3 -0
  57. package/dist/outbound-schemas.js +3 -0
  58. package/dist/outbound-schemas.js.map +1 -1
  59. package/dist/paths.d.ts +19 -0
  60. package/dist/paths.js +18 -5
  61. package/dist/paths.js.map +1 -1
  62. package/dist/tool-permissions.js +4 -2
  63. package/dist/tool-permissions.js.map +1 -1
  64. package/dist/ui/web-terminal/terminal-input.js +69 -0
  65. package/dist/ui/web-terminal/terminal.css +6 -0
  66. package/dist/ui/web-terminal/terminal.html +9 -1
  67. package/dist/ui/web-terminal/terminal.js +31 -2
  68. package/dist/web-terminal-http.js +1 -0
  69. package/dist/web-terminal-http.js.map +1 -1
  70. package/package.json +1 -1
@@ -0,0 +1,99 @@
1
+ /**
2
+ * The command table: for every command AgEnD answers, WHERE it applies and WHO may use it.
3
+ *
4
+ * Before this there was no single place to look. A slash command's lock emoji was typed by hand into its
5
+ * description, its gate lived in whichever of two copied handlers dispatched it, and the same command had a
6
+ * different gate in a fleet channel, a ClassicBot channel and as typed text. The audit of #1148 found `/compact`
7
+ * labelled 🔒 and open to everyone, `/stop` open on Discord and admin-only on Telegram, `/save` judged by the wrong
8
+ * kind of admin — all of them the same mistake: the rule and the label were allowed to disagree.
9
+ *
10
+ * This file is pure data plus one pure function, so it can be read, and tested cell by cell, without a client.
11
+ *
12
+ * It sits BEHIND the door in src/slash-authz.ts and never in front of it. The door decides whether the caller may
13
+ * speak here at all (not a DM, an acceptable guild, the access policy of the channel's owner, fail closed). Only
14
+ * then does this ask whether the command applies in this kind of channel and which kind of admin it needs. It can
15
+ * therefore only narrow what the door let through; nothing here is a way around it.
16
+ *
17
+ * Levels
18
+ * - anyone whoever the door admitted (in a fleet channel: someone the access policy hears; in a ClassicBot
19
+ * channel: everyone, as for typed messages)
20
+ * - channel-admin the admin of the channel's own kind: in a fleet channel an explicit fleet admin, in a ClassicBot
21
+ * channel a fleet admin OR a ClassicBot admin (what `isModelAdmin` always meant)
22
+ * - fleet-admin an explicit entry in the INVOKING adapter's `allowed_users`; an empty list means nobody
23
+ * - classic-admin a ClassicBot admin only (`admin_users`)
24
+ * - handler the door has admitted the caller and the table asks nothing more: the command's own handler
25
+ * decides, because the rule is not one level. Only `/start` is like this — an allowlist that is a
26
+ * different list on each platform (see `start` below). A command with this level is not locked.
27
+ *
28
+ * Platforms: the table is read per platform. `scopes` is what the Discord slash dispatcher enforces; `telegram`, where
29
+ * present, holds the cells where Telegram's own handlers gate differently (they are not table-driven yet, and each
30
+ * override is pinned against the real handlers by tests/command-gates-by-platform.test.ts). The audit that produced
31
+ * this table once folded both platforms into one level per command and lost Telegram's side of it; a cell that holds
32
+ * for Discord is not thereby true for Telegram, and "most restrictive wins" is not a way to merge them.
33
+ *
34
+ * Scopes: `fleet` is an instance's own channel, `general` the General dispatcher (also a fleet channel, but some
35
+ * commands differ there), `classic` a ClassicBot channel, `none` a channel AgEnD has no agent in.
36
+ */
37
+ export type CommandScope = "fleet" | "general" | "classic" | "none";
38
+ export type CommandLevel = "anyone" | "channel-admin" | "fleet-admin" | "classic-admin" | "handler";
39
+ export type Platform = "discord" | "telegram";
40
+ /** A locale key, optionally with arguments (`cmd.admin_required` wants the command name). */
41
+ export type Reply = readonly [key: string, ...args: string[]];
42
+ /**
43
+ * One scope on one platform. `passthrough`: there is no handler for this command here — the typed text is not a
44
+ * command, it goes to the agent like any message (or, in a chat with no agent, is dropped). It is NOT a refusal and
45
+ * grants nothing; it exists so that "Telegram has no such command" is stated instead of being read off Discord's cell.
46
+ */
47
+ export type PlatformRule = ScopeRule | {
48
+ readonly passthrough: true;
49
+ };
50
+ export type ScopeRule = {
51
+ readonly level: CommandLevel; /** For `handler`: what the handler checks, per platform. */
52
+ readonly note?: string;
53
+ }
54
+ /** The command does not apply in this kind of channel: say so, with this reply, and do nothing. */
55
+ | {
56
+ readonly refuse: Reply;
57
+ };
58
+ export interface CommandSpec {
59
+ readonly name: string;
60
+ /** Appears in the Discord slash menu. */
61
+ readonly slash: boolean;
62
+ /** What the Discord slash dispatcher enforces. */
63
+ readonly scopes: Readonly<Record<CommandScope, ScopeRule>>;
64
+ /** What Telegram's own handlers do, every scope written out (nothing is inherited from `scopes`). */
65
+ readonly telegram: Readonly<Record<CommandScope, PlatformRule>>;
66
+ /** Said when the caller is not at the required level. */
67
+ readonly denied: Reply;
68
+ /** Said instead of `denied` when a fleet-admin command is off because the adapter lists no admins at all. */
69
+ readonly disabled?: Reply;
70
+ }
71
+ export declare const COMMANDS: readonly CommandSpec[];
72
+ export declare function commandSpec(name: string): CommandSpec | undefined;
73
+ /** The rule for one scope on one platform: the Discord column for Discord, the Telegram column for Telegram. */
74
+ export declare function ruleFor(spec: CommandSpec, scope: CommandScope, platform: "discord"): ScopeRule;
75
+ export declare function ruleFor(spec: CommandSpec, scope: CommandScope, platform: Platform): PlatformRule;
76
+ /** True when some scope the command applies in asks for more than `anyone` — what the lock emoji says. */
77
+ export declare function isLocked(spec: CommandSpec): boolean;
78
+ /** The prefix for a slash command's menu description: generated, never typed. Unknown names get none. */
79
+ export declare function slashLock(name: string): string;
80
+ export type FleetAdminState = "ok" | "disabled" | "denied";
81
+ /** What the caller knows about the person; each is asked only if the command's level needs it. */
82
+ export interface CommandChecks {
83
+ fleetAdmin(): FleetAdminState;
84
+ channelAdmin(): boolean;
85
+ classicAdmin(): boolean;
86
+ }
87
+ export type CommandDecision = {
88
+ allow: true;
89
+ } | {
90
+ allow: false;
91
+ reply: Reply;
92
+ };
93
+ /** On Telegram a cell can also be "not a command here" (see `PlatformRule`): nothing to authorize, refuse or run. */
94
+ export type PlatformDecision = CommandDecision | {
95
+ allow: false;
96
+ passthrough: true;
97
+ };
98
+ export declare function decideCommand(spec: CommandSpec, scope: CommandScope, checks: CommandChecks, platform?: "discord"): CommandDecision;
99
+ export declare function decideCommand(spec: CommandSpec, scope: CommandScope, checks: CommandChecks, platform: Platform): PlatformDecision;
@@ -0,0 +1,125 @@
1
+ const SCOPES = ["fleet", "general", "classic", "none"];
2
+ /** The same level everywhere. */
3
+ const everywhere = (level) => ({ fleet: { level }, general: { level }, classic: { level }, none: { level } });
4
+ /** An agent's own channel (fleet, General, ClassicBot): `level` there, `otherwise` anywhere else. */
5
+ const inAgentChannels = (level, otherwise) => ({ fleet: { level }, general: { level }, classic: { level }, none: { refuse: otherwise } });
6
+ /** ClassicBot channels only: `level` there, `otherwise` everywhere else. */
7
+ const classicOnly = (level, otherwise) => ({ fleet: { refuse: otherwise }, general: { refuse: otherwise }, classic: { level }, none: { refuse: otherwise } });
8
+ const NOT_AUTHORIZED = ["not_authorized"];
9
+ const PERMISSION_DENIED = ["permission.denied"];
10
+ const NO_AGENT = ["classic.no_agent"];
11
+ const NO_AGENT_START = ["classic.no_agent_start"];
12
+ /**
13
+ * The Telegram column, written out for EVERY scope of every command: a Telegram cell is never inherited from Discord,
14
+ * because most of them differ (a command can have no Telegram handler at all). Scopes are the same four: `general` is
15
+ * the General topic, `fleet` an instance's own topic, `classic` a registered ClassicBot chat (private, or a group
16
+ * addressed with `/cmd@bot`), `none` a chat with no agent (an unregistered group or private chat).
17
+ */
18
+ const PASS = { passthrough: true };
19
+ const ANYONE = { level: "anyone" };
20
+ const FLEET_ADMIN = { level: "fleet-admin" };
21
+ const CLASSIC_ADMIN = { level: "classic-admin" };
22
+ const CHANNEL_ADMIN = { level: "channel-admin" };
23
+ /** A chat with no agent: the command exists on Telegram and checks the caller, then finds nothing to act on — nobody gets it. */
24
+ const NOBODY_NO_AGENT = { refuse: NO_AGENT };
25
+ const NOBODY_NO_AGENT_START = { refuse: NO_AGENT_START };
26
+ const HANDLER = { level: "handler", note: "Telegram private: user allowlist. Telegram group: group allowlist + ClassicBot admin." };
27
+ const tg = (general, fleet, classic, none = PASS) => ({ general, fleet, classic, none });
28
+ /** Only the General topic has a handler: everywhere else the typed text is an ordinary message to the agent. */
29
+ const tgGeneralOnly = (general) => tg(general, PASS, PASS);
30
+ export const COMMANDS = [
31
+ // ── ClassicBot lifecycle ──
32
+ {
33
+ name: "start", slash: true, denied: NOT_AUTHORIZED,
34
+ // Not one level. Discord: the guild allowlist (`isGuildAllowed`) and nothing else — no admin. Telegram private chat:
35
+ // `isUserAllowed`. Telegram group: `isGroupAllowed` AND a ClassicBot admin. The handler enforces each (the table
36
+ // only says where it applies), so the single word "anyone" that stood here was wrong for Telegram groups.
37
+ scopes: {
38
+ fleet: { refuse: ["classic.topic_bound"] }, general: { refuse: ["classic.topic_bound"] }, classic: { refuse: ["classic.already_active"] },
39
+ none: { level: "handler", note: "Discord: guild allowlist. Telegram private: user allowlist. Telegram group: group allowlist + ClassicBot admin." },
40
+ },
41
+ telegram: tg(PASS, PASS, HANDLER, HANDLER),
42
+ },
43
+ // A ClassicBot admin on both platforms. Telegram always required one; Discord used to require nothing ("the guild
44
+ // allowlist is the trust boundary"), then briefly a channel admin, which let a fleet admin stop a channel Telegram
45
+ // would not have let them stop.
46
+ { name: "stop", slash: true, denied: ["classic.admin_only_stop"], scopes: classicOnly("classic-admin", NO_AGENT), telegram: tg(PASS, PASS, CLASSIC_ADMIN, NOBODY_NO_AGENT) },
47
+ { name: "chat", slash: true, denied: NOT_AUTHORIZED, scopes: classicOnly("anyone", NO_AGENT_START), telegram: tg(PASS, PASS, ANYONE) },
48
+ // Discord only: on Telegram `/load` is not a command (the text goes to the agent).
49
+ { name: "load", slash: true, denied: ["admin.required"], scopes: classicOnly("classic-admin", NO_AGENT_START), telegram: tg(PASS, PASS, PASS) },
50
+ // ── Per-agent controls ──
51
+ // Telegram's ClassicBot handler asks `isAdmin` (a ClassicBot admin only; a fleet admin alone is refused), where Discord
52
+ // asks `isModelAdmin` (a fleet admin or a ClassicBot admin).
53
+ { name: "pause", slash: true, denied: PERMISSION_DENIED, scopes: inAgentChannels("channel-admin", NO_AGENT), telegram: tg(FLEET_ADMIN, FLEET_ADMIN, CLASSIC_ADMIN, NOBODY_NO_AGENT_START) },
54
+ { name: "wake", slash: true, denied: PERMISSION_DENIED, scopes: inAgentChannels("channel-admin", NO_AGENT), telegram: tg(FLEET_ADMIN, FLEET_ADMIN, CLASSIC_ADMIN, NOBODY_NO_AGENT_START) },
55
+ // Discord had no check at all (the 🔒 was only a label). Telegram: a ClassicBot admin in a ClassicBot chat, and NO
56
+ // check in a fleet topic.
57
+ { name: "compact", slash: true, denied: ["cmd.admin_required", "/compact"], scopes: inAgentChannels("channel-admin", NO_AGENT), telegram: tg(ANYONE, ANYONE, CLASSIC_ADMIN, NOBODY_NO_AGENT_START) },
58
+ { name: "clear", slash: true, denied: PERMISSION_DENIED, scopes: inAgentChannels("channel-admin", NO_AGENT), telegram: tg(FLEET_ADMIN, FLEET_ADMIN, CHANNEL_ADMIN, NOBODY_NO_AGENT_START) },
59
+ { name: "model", slash: true, denied: PERMISSION_DENIED, scopes: inAgentChannels("channel-admin", NO_AGENT), telegram: tg(FLEET_ADMIN, FLEET_ADMIN, CHANNEL_ADMIN, NOBODY_NO_AGENT_START) },
60
+ // Telegram has no ClassicBot /effort handler (the text goes to the agent).
61
+ { name: "effort", slash: true, denied: PERMISSION_DENIED, scopes: inAgentChannels("channel-admin", NO_AGENT), telegram: tg(FLEET_ADMIN, FLEET_ADMIN, PASS) },
62
+ // Telegram's typed /collab has no check in a fleet topic and no handler in a ClassicBot chat (the text goes to the agent).
63
+ { name: "collab", slash: true, denied: NOT_AUTHORIZED, scopes: inAgentChannels("channel-admin", NO_AGENT_START), telegram: tg(ANYONE, ANYONE, PASS) },
64
+ // Telegram: a ClassicBot admin in a ClassicBot chat, no check in a fleet topic (same as /compact).
65
+ { name: "save", slash: true, denied: ["admin.required"], scopes: inAgentChannels("channel-admin", NO_AGENT_START), telegram: tg(ANYONE, ANYONE, CLASSIC_ADMIN, NOBODY_NO_AGENT_START) },
66
+ // Anyone who can talk to the agent may talk to it mid-turn, ask a side question, interrupt it or read its context.
67
+ { name: "steer", slash: true, denied: NOT_AUTHORIZED, scopes: inAgentChannels("anyone", NO_AGENT), telegram: tg(ANYONE, ANYONE, ANYONE, NOBODY_NO_AGENT_START) },
68
+ { name: "btw", slash: true, denied: NOT_AUTHORIZED, scopes: inAgentChannels("anyone", NO_AGENT), telegram: tg(ANYONE, ANYONE, ANYONE, NOBODY_NO_AGENT_START) },
69
+ { name: "cancel", slash: true, denied: NOT_AUTHORIZED, scopes: inAgentChannels("anyone", NO_AGENT), telegram: tg(ANYONE, ANYONE, ANYONE, NOBODY_NO_AGENT_START) },
70
+ { name: "ctx", slash: true, denied: NOT_AUTHORIZED, scopes: inAgentChannels("anyone", NO_AGENT), telegram: tg(ANYONE, ANYONE, ANYONE, NOBODY_NO_AGENT_START) },
71
+ // ── The fleet, from anywhere ── (on Telegram: only the General topic has these handlers)
72
+ { name: "status", slash: true, denied: ["cmd.admin_required", "/status"], scopes: everywhere("fleet-admin"), telegram: tgGeneralOnly(FLEET_ADMIN) },
73
+ { name: "restart", slash: true, denied: NOT_AUTHORIZED, scopes: everywhere("fleet-admin"), telegram: tgGeneralOnly(FLEET_ADMIN) },
74
+ { name: "login", slash: true, denied: PERMISSION_DENIED, scopes: everywhere("fleet-admin"), telegram: tgGeneralOnly(FLEET_ADMIN) },
75
+ { name: "update", slash: true, denied: NOT_AUTHORIZED, disabled: ["update.disabled"], scopes: everywhere("fleet-admin"), telegram: tgGeneralOnly(FLEET_ADMIN) },
76
+ { name: "doctor", slash: true, denied: NOT_AUTHORIZED, scopes: everywhere("fleet-admin"), telegram: tgGeneralOnly(FLEET_ADMIN) },
77
+ { name: "dashboard", slash: true, denied: NOT_AUTHORIZED, disabled: ["dashboard.disabled"], scopes: everywhere("fleet-admin"), telegram: tgGeneralOnly(FLEET_ADMIN) },
78
+ // Informational. `/tips on|off|advanced on` change settings and are gated by the handler on that argument.
79
+ { name: "sysinfo", slash: true, denied: NOT_AUTHORIZED, scopes: everywhere("anyone"), telegram: tgGeneralOnly(ANYONE) },
80
+ { name: "usage", slash: true, denied: NOT_AUTHORIZED, scopes: everywhere("anyone"), telegram: tgGeneralOnly(ANYONE) },
81
+ // On Telegram it also works in an instance's topic.
82
+ { name: "tips", slash: true, denied: PERMISSION_DENIED, scopes: everywhere("anyone"), telegram: tg(ANYONE, ANYONE, PASS) },
83
+ ];
84
+ const BY_NAME = new Map(COMMANDS.map(spec => [spec.name, spec]));
85
+ export function commandSpec(name) {
86
+ return BY_NAME.get(name);
87
+ }
88
+ export function ruleFor(spec, scope, platform) {
89
+ return platform === "telegram" ? spec.telegram[scope] : spec.scopes[scope];
90
+ }
91
+ /** True when some scope the command applies in asks for more than `anyone` — what the lock emoji says. */
92
+ export function isLocked(spec) {
93
+ return SCOPES.some(scope => {
94
+ const rule = spec.scopes[scope];
95
+ return "level" in rule && rule.level !== "anyone" && rule.level !== "handler";
96
+ });
97
+ }
98
+ /** The prefix for a slash command's menu description: generated, never typed. Unknown names get none. */
99
+ export function slashLock(name) {
100
+ const spec = BY_NAME.get(name);
101
+ return spec && isLocked(spec) ? "🔒 " : "";
102
+ }
103
+ export function decideCommand(spec, scope, checks, platform = "discord") {
104
+ const rule = ruleFor(spec, scope, platform);
105
+ if ("passthrough" in rule)
106
+ return { allow: false, passthrough: true };
107
+ if ("refuse" in rule)
108
+ return { allow: false, reply: rule.refuse };
109
+ switch (rule.level) {
110
+ case "anyone":
111
+ case "handler":
112
+ return { allow: true };
113
+ case "channel-admin":
114
+ return checks.channelAdmin() ? { allow: true } : { allow: false, reply: spec.denied };
115
+ case "classic-admin":
116
+ return checks.classicAdmin() ? { allow: true } : { allow: false, reply: spec.denied };
117
+ case "fleet-admin": {
118
+ const state = checks.fleetAdmin();
119
+ if (state === "ok")
120
+ return { allow: true };
121
+ return { allow: false, reply: state === "disabled" && spec.disabled ? spec.disabled : spec.denied };
122
+ }
123
+ }
124
+ }
125
+ //# sourceMappingURL=command-table.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"command-table.js","sourceRoot":"","sources":["../src/command-table.ts"],"names":[],"mappings":"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"}
package/dist/daemon.d.ts CHANGED
@@ -474,6 +474,12 @@ export declare class Daemon extends EventEmitter {
474
474
  /** Identity of that dialog: its pattern, not its description (two tables may describe one screen differently). */
475
475
  private dialogParkedKey;
476
476
  private dialogParkedReported;
477
+ /**
478
+ * The runtime dialog the monitor is answering, and how many of its answers in a row did nothing (the dialog was
479
+ * still on screen right after the keys). Not the on-screen duration: a burst of FRESH prompts, each answered, has a
480
+ * dialog on screen at every poll and ignores nothing.
481
+ */
482
+ private dialogAnswers;
477
483
  /** Current stdin-owning runtime dialog, independent from execution state. */
478
484
  private inputBlockedDialogKey;
479
485
  /** Generation/signature fence for safety dialogs while their screen is still visible. */
@@ -699,6 +705,13 @@ export declare class Daemon extends EventEmitter {
699
705
  private mcpDeathDeferredForPid;
700
706
  /** Prevent in-flight monitor callbacks from re-arming after a pause. */
701
707
  private runtimeMonitorsFrozen;
708
+ /**
709
+ * Bumped by every stop / pause / startup abort (they all freeze the monitors). A launch that has to
710
+ * await something before it touches the pane (a backend's `prepareLaunch`) remembers the value it
711
+ * started under and gives up if it moved: a stopped instance must not get a window, a config or a
712
+ * token from a launch that was already in the air. A wake that STARTS after the freeze is unaffected.
713
+ */
714
+ private launchFenceEpoch;
702
715
  private errorWaitingForRecovery;
703
716
  private errorDetectedAt;
704
717
  private errorRecoveryDeadlineAt;
@@ -1325,6 +1338,29 @@ export declare class Daemon extends EventEmitter {
1325
1338
  * within a bound and then fail loudly.
1326
1339
  */
1327
1340
  private probeBlockingDialog;
1341
+ /** What identifies the request on screen: the backend's own identity when it has one, else the whole screen verbatim. */
1342
+ private static screenOf;
1343
+ private static answerKey;
1344
+ /**
1345
+ * The episode being counted is over: its count and its report. Its parked clock ends too (`clearParked`) only when the
1346
+ * dialog is gone or a different request is positively identified — not on a difference nobody can explain.
1347
+ */
1348
+ private endDialogEpisode;
1349
+ /**
1350
+ * A poll that did not complete an answer-and-look (lock busy, keys not sent, nothing to press) breaks "in a row" for
1351
+ * this dialog; if the dialog on screen is a DIFFERENT one, the episode being counted belongs to a screen that is gone.
1352
+ */
1353
+ private breakAnswerStreak;
1354
+ /**
1355
+ * One answer of the runtime scanner to a dialog, and whether the SAME dialog (same kind, same content) was still
1356
+ * on screen right after it.
1357
+ * An answer that took ends the episode (what shows next is a new dialog, so the parked clock restarts too);
1358
+ * DIALOG_ANSWER_IGNORED_MAX in a row that did not is reported ONCE per episode, for ANY backend's dialog —
1359
+ * not only the ones that hold deliveries — so a human is told in about a quarter of a minute instead of the
1360
+ * instance sitting there until a hang detector (and only with pending work) notices. The event carries the
1361
+ * dialog's static description and a count, never the pane: a pane can hold anything the agent printed.
1362
+ */
1363
+ private noteDialogAnswer;
1328
1364
  /**
1329
1365
  * Remember how long a blocking dialog has been on screen. Once it has
1330
1366
  * outlived DIALOG_PARKED_NOTIFY_MS — the auto-dismiss did not take, or the
package/dist/daemon.js CHANGED
@@ -447,6 +447,18 @@ const STARTUP_DIALOG_POLL_MS = 500;
447
447
  const STARTUP_DIALOG_BUDGET_MS = 30_000;
448
448
  /** A blocking dialog still on screen this long after first being seen is reported for a human. */
449
449
  const DIALOG_PARKED_NOTIFY_MS = 60_000;
450
+ /** After a runtime dialog's keys, how long the pane gets to repaint before it is read again to see whether they took. */
451
+ const DIALOG_ANSWER_SETTLE_MS = 300;
452
+ /** Consecutive answers one runtime dialog ignored (still on screen right after the keys) before it is reported for a human. */
453
+ const DIALOG_ANSWER_IGNORED_MAX = 3;
454
+ /**
455
+ * Once a dialog has been reported as ignoring the answer, how long the scanner waits between answers to the SAME request.
456
+ * Not a stop: requests that merely look alike (several queued permission requests for one directory) cannot be told
457
+ * apart from an ignored answer, and each still needs its answer — they get it, slowly — while a dialog that really
458
+ * ignores the keys is pressed far less. A different request, the dialog going away, or a new spawn restores the
459
+ * normal pace (and a fresh count).
460
+ */
461
+ const DIALOG_ANSWER_BACKOFF_MS = 30_000;
450
462
  /** How many "dialog painted just before the write → wait → retry" rounds a delivery tolerates. */
451
463
  const LATE_DIALOG_WRITE_ROUNDS = 3;
452
464
  /** How many times a delivery redoes itself when a spawn starts between its settle wait and its pane write. */
@@ -1024,6 +1036,12 @@ export class Daemon extends EventEmitter {
1024
1036
  /** Identity of that dialog: its pattern, not its description (two tables may describe one screen differently). */
1025
1037
  dialogParkedKey = null;
1026
1038
  dialogParkedReported = false;
1039
+ /**
1040
+ * The runtime dialog the monitor is answering, and how many of its answers in a row did nothing (the dialog was
1041
+ * still on screen right after the keys). Not the on-screen duration: a burst of FRESH prompts, each answered, has a
1042
+ * dialog on screen at every poll and ignores nothing.
1043
+ */
1044
+ dialogAnswers = null;
1027
1045
  /** Current stdin-owning runtime dialog, independent from execution state. */
1028
1046
  inputBlockedDialogKey = null;
1029
1047
  /** Generation/signature fence for safety dialogs while their screen is still visible. */
@@ -1260,6 +1278,13 @@ export class Daemon extends EventEmitter {
1260
1278
  mcpDeathDeferredForPid = null;
1261
1279
  /** Prevent in-flight monitor callbacks from re-arming after a pause. */
1262
1280
  runtimeMonitorsFrozen = false;
1281
+ /**
1282
+ * Bumped by every stop / pause / startup abort (they all freeze the monitors). A launch that has to
1283
+ * await something before it touches the pane (a backend's `prepareLaunch`) remembers the value it
1284
+ * started under and gives up if it moved: a stopped instance must not get a window, a config or a
1285
+ * token from a launch that was already in the air. A wake that STARTS after the freeze is unaffected.
1286
+ */
1287
+ launchFenceEpoch = 0;
1263
1288
  errorWaitingForRecovery = false; // true = error detected, waiting for ready pattern
1264
1289
  errorDetectedAt = 0;
1265
1290
  errorRecoveryDeadlineAt = 0;
@@ -2654,7 +2679,7 @@ export class Daemon extends EventEmitter {
2654
2679
  return false;
2655
2680
  this.healthCheckPaused = true;
2656
2681
  this.logger.error({ reason: err.message }, "Not respawning: the installed CLI cannot run this instance as configured");
2657
- this.emitSupervisionEnded(err.message, "Install a CLI version that supports this instance's configuration, then start it again.");
2682
+ this.emitSupervisionEnded(err.message, "Install a CLI version that supports this instance's configuration, then start it again.", undefined, "cli_unsupported");
2658
2683
  return true;
2659
2684
  }
2660
2685
  /**
@@ -2670,12 +2695,13 @@ export class Daemon extends EventEmitter {
2670
2695
  * `crash_loop` already had this treatment; this is the same bridge for the other
2671
2696
  * four, carrying a human-readable cause and the operator's next step.
2672
2697
  */
2673
- emitSupervisionEnded(reason, remedy, exitCode) {
2698
+ emitSupervisionEnded(reason, remedy, exitCode, cause) {
2674
2699
  this.emit("supervision_ended", {
2675
2700
  name: this.name,
2676
2701
  reason,
2677
2702
  remedy,
2678
2703
  ...(exitCode !== undefined ? { exitCode } : {}),
2704
+ ...(cause ? { cause } : {}),
2679
2705
  });
2680
2706
  }
2681
2707
  setProcessStatus(status) {
@@ -2725,11 +2751,18 @@ export class Daemon extends EventEmitter {
2725
2751
  this.errorMonitorTimer = setInterval(async () => {
2726
2752
  if (!this.tmux || this.spawning)
2727
2753
  return;
2754
+ // This poll belongs to the spawn and the monitors it started under. Every await below can outlive them (a stop,
2755
+ // a pause, a respawn); a stale poll must not touch state a newer one owns, so it checks before committing.
2756
+ const pollSpawn = this.spawnGeneration;
2757
+ const pollFence = this.launchFenceEpoch;
2758
+ const stale = () => pollSpawn !== this.spawnGeneration || pollFence !== this.launchFenceEpoch;
2728
2759
  try {
2729
2760
  const alive = await this.tmux.isWindowAlive();
2730
2761
  if (!alive)
2731
2762
  return;
2732
2763
  const pane = await this.tmux.capturePane();
2764
+ if (stale())
2765
+ return;
2733
2766
  const inputBlockedDialog = this.updateInputBlockedState(pane, dialogs);
2734
2767
  // A sign-in screen that appears AFTER the startup scan ended (first run:
2735
2768
  // theme → login can take longer than its budget) is the same auth
@@ -2769,6 +2802,7 @@ export class Daemon extends EventEmitter {
2769
2802
  // on). Runtime table only: startup patterns are loose on purpose and
2770
2803
  // must never send keys into an ordinary transcript.
2771
2804
  let blockingSeen = null;
2805
+ let answersBackedOff = false;
2772
2806
  for (const dialog of dialogs) {
2773
2807
  if (!Daemon.dialogMatches(dialog, pane))
2774
2808
  continue;
@@ -2777,6 +2811,20 @@ export class Daemon extends EventEmitter {
2777
2811
  blockingSeen = dialog;
2778
2812
  if (dialog.holdOnly)
2779
2813
  break; // recognised, deliberately not answered; trackDialogParked reports it
2814
+ // Reported as ignoring the daemon: the SAME request (the identity the count was made on — verbatim, the
2815
+ // backend's own when it names one) is answered only once per DIALOG_ANSWER_BACKOFF_MS. Anything else — another
2816
+ // request, an unexplained difference, the dialog gone — is not held back here; the answer path below ends or
2817
+ // keeps the episode by its own rules.
2818
+ const episode = this.dialogAnswers;
2819
+ // A wall clock that moved BACKWARDS since the last answer (NTP step, VM resume) gives a negative elapsed time:
2820
+ // the backoff has expired, not "not started" — held to it, the answers would stop until the clock caught up.
2821
+ const sinceLastAnswer = episode ? Date.now() - episode.lastAnswerAt : 0;
2822
+ if (episode?.reported && episode.key === Daemon.answerKey(dialog)
2823
+ && episode.screen === Daemon.screenOf(dialog, pane)
2824
+ && sinceLastAnswer >= 0 && sinceLastAnswer < DIALOG_ANSWER_BACKOFF_MS) {
2825
+ answersBackedOff = true;
2826
+ continue;
2827
+ }
2780
2828
  const autoKey = dialog.autoResolutionKey;
2781
2829
  if (dialog.verifyAfterKeys && autoKey
2782
2830
  && this.autoResolvedDialogGeneration === this.spawnGeneration
@@ -2796,10 +2844,23 @@ export class Daemon extends EventEmitter {
2796
2844
  // while a delivery was finishing. A stale danger menu must never
2797
2845
  // receive a blind key sequence.
2798
2846
  const currentPane = await this.tmux.capturePane();
2847
+ // Every await below can outlive the spawn / monitors this poll started under: after each one, before
2848
+ // any state is touched or any further key is sent, `stale()` (fixed at the poll's start) is asked again.
2849
+ if (stale())
2850
+ return;
2799
2851
  if (!Daemon.dialogMatches(dialog, currentPane)) {
2800
2852
  this.updateInputBlockedState(currentPane, dialogs);
2853
+ this.endDialogEpisode(); // somebody (or the CLI) already cleared it: that screen is over
2801
2854
  return;
2802
2855
  }
2856
+ // What is being answered: the request's identity when the backend can name it (verbatim, without whatever
2857
+ // ticks around it), else the whole screen.
2858
+ const before = Daemon.screenOf(dialog, currentPane);
2859
+ // Another request since the last answer: the count and the report belong to that one. Its parked clock goes
2860
+ // too only when the backend vouches for the identity — an unrecognised difference (a timer, a spinner) is
2861
+ // not evidence that the old request was answered, and the one-minute fallback must keep running.
2862
+ if (this.dialogAnswers?.key === Daemon.answerKey(dialog) && this.dialogAnswers.screen !== before)
2863
+ this.endDialogEpisode(dialog.requestIdentity !== undefined);
2803
2864
  if (dialog.verifyAfterKeys && autoKey
2804
2865
  && this.autoResolvedDialogGeneration === this.spawnGeneration
2805
2866
  && this.autoResolvedDialogKey === autoKey)
@@ -2818,17 +2879,24 @@ export class Daemon extends EventEmitter {
2818
2879
  else {
2819
2880
  sent = await this.tmux.pasteText(key, this.systemPasteOptions());
2820
2881
  }
2882
+ if (stale())
2883
+ return;
2821
2884
  if (!sent) {
2822
2885
  if (dialog.verifyAfterKeys && autoKey) {
2823
2886
  this.autoResolvedDialogGeneration = 0;
2824
2887
  this.autoResolvedDialogKey = null;
2825
2888
  }
2889
+ this.breakAnswerStreak(dialog);
2826
2890
  return;
2827
2891
  }
2828
2892
  await new Promise(r => setTimeout(r, 200));
2893
+ if (stale())
2894
+ return;
2829
2895
  }
2830
2896
  if (dialog.verifyAfterKeys) {
2831
2897
  const afterKeysPane = await this.tmux.capturePane();
2898
+ if (stale())
2899
+ return;
2832
2900
  const dialogStillActive = dialog.inputBlocked
2833
2901
  ? dialogs.some(candidate => candidate.inputBlocked && Daemon.dialogMatches(candidate, afterKeysPane))
2834
2902
  : Daemon.dialogMatches(dialog, afterKeysPane);
@@ -2844,8 +2912,27 @@ export class Daemon extends EventEmitter {
2844
2912
  resolved = true;
2845
2913
  }
2846
2914
  }
2915
+ else if (dialog.keys.length > 0) {
2916
+ // Did the keys take? Read the pane again. The same request still there is an answer that did nothing. A request
2917
+ // the backend names as DIFFERENT (the next queued one), or no dialog at all, means it took. Without a
2918
+ // backend-named identity any difference on the screen (a ticking timer) proves nothing either way: nothing
2919
+ // is reported and the parked clock is left alone. Two requests that look exactly alike cannot be told apart.
2920
+ await new Promise(r => setTimeout(r, DIALOG_ANSWER_SETTLE_MS));
2921
+ const afterPane = await this.tmux.capturePane();
2922
+ if (stale())
2923
+ return; // (nothing was touched since the last check: only a read)
2924
+ const stillThere = Daemon.dialogMatches(dialog, afterPane);
2925
+ const same = stillThere && Daemon.screenOf(dialog, afterPane) === before;
2926
+ this.noteDialogAnswer(dialog, same ? "ignored" : (!stillThere || dialog.requestIdentity) ? "took" : "unproven", before);
2927
+ }
2928
+ else {
2929
+ this.breakAnswerStreak(dialog);
2930
+ }
2847
2931
  });
2932
+ if (stale())
2933
+ return; // the answer-and-look outlived its spawn / monitors: it commits nothing
2848
2934
  if (!dismissed) {
2935
+ this.breakAnswerStreak(dialog);
2849
2936
  this.logger.info({ dialog: dialog.description }, "Dialog dismissal deferred — pane write in flight");
2850
2937
  }
2851
2938
  else if (dialog.verifyAfterKeys && !resolved) {
@@ -2854,6 +2941,11 @@ export class Daemon extends EventEmitter {
2854
2941
  this.trackDialogParked(blockingSeen);
2855
2942
  return; // Dialog handled (or deliberately deferred): skip error checks this cycle
2856
2943
  }
2944
+ // Reached only when no dialog was answered this poll (none on screen, a fenced one, or one waiting out its
2945
+ // backoff): unless it is the one backing off, the screen that was being answered is gone, and the next dialog is
2946
+ // a new one.
2947
+ if (!answersBackedOff)
2948
+ this.dialogAnswers = null;
2857
2949
  this.trackDialogParked(blockingSeen);
2858
2950
  if (blockingSeen)
2859
2951
  return; // held dialog: skip error checks this cycle
@@ -4649,6 +4741,7 @@ export class Daemon extends EventEmitter {
4649
4741
  /** Stop every runtime poller/watcher while preserving IPC and daemon state. */
4650
4742
  freezeRuntimeMonitors() {
4651
4743
  this.runtimeMonitorsFrozen = true;
4744
+ this.launchFenceEpoch++;
4652
4745
  if (this.healthCheckTimer) {
4653
4746
  clearTimeout(this.healthCheckTimer);
4654
4747
  this.healthCheckTimer = null;
@@ -5716,6 +5809,64 @@ export class Daemon extends EventEmitter {
5716
5809
  this.trackDialogParked(null);
5717
5810
  return { state: "clear" };
5718
5811
  }
5812
+ /** What identifies the request on screen: the backend's own identity when it has one, else the whole screen verbatim. */
5813
+ static screenOf(dialog, pane) {
5814
+ return dialog.requestIdentity?.(pane) ?? pane;
5815
+ }
5816
+ static answerKey(dialog) {
5817
+ return `${Daemon.dialogKey(dialog)}\0${dialog.description}`;
5818
+ }
5819
+ /**
5820
+ * The episode being counted is over: its count and its report. Its parked clock ends too (`clearParked`) only when the
5821
+ * dialog is gone or a different request is positively identified — not on a difference nobody can explain.
5822
+ */
5823
+ endDialogEpisode(clearParked = true) {
5824
+ this.dialogAnswers = null;
5825
+ if (clearParked)
5826
+ this.trackDialogParked(null);
5827
+ }
5828
+ /**
5829
+ * A poll that did not complete an answer-and-look (lock busy, keys not sent, nothing to press) breaks "in a row" for
5830
+ * this dialog; if the dialog on screen is a DIFFERENT one, the episode being counted belongs to a screen that is gone.
5831
+ */
5832
+ breakAnswerStreak(dialog) {
5833
+ if (!this.dialogAnswers)
5834
+ return;
5835
+ if (this.dialogAnswers.key === Daemon.answerKey(dialog))
5836
+ this.dialogAnswers.ignored = 0;
5837
+ else
5838
+ this.endDialogEpisode();
5839
+ }
5840
+ /**
5841
+ * One answer of the runtime scanner to a dialog, and whether the SAME dialog (same kind, same content) was still
5842
+ * on screen right after it.
5843
+ * An answer that took ends the episode (what shows next is a new dialog, so the parked clock restarts too);
5844
+ * DIALOG_ANSWER_IGNORED_MAX in a row that did not is reported ONCE per episode, for ANY backend's dialog —
5845
+ * not only the ones that hold deliveries — so a human is told in about a quarter of a minute instead of the
5846
+ * instance sitting there until a hang detector (and only with pending work) notices. The event carries the
5847
+ * dialog's static description and a count, never the pane: a pane can hold anything the agent printed.
5848
+ */
5849
+ noteDialogAnswer(dialog, outcome, screen) {
5850
+ const key = Daemon.answerKey(dialog);
5851
+ if (!this.dialogAnswers || this.dialogAnswers.key !== key)
5852
+ this.dialogAnswers = { key, ignored: 0, reported: false, screen, lastAnswerAt: 0 }; // the exact screen being counted: every answer in the episode saw this same one
5853
+ const episode = this.dialogAnswers;
5854
+ if (outcome !== "ignored") {
5855
+ this.endDialogEpisode(outcome === "took");
5856
+ return;
5857
+ }
5858
+ episode.ignored++;
5859
+ episode.lastAnswerAt = Date.now();
5860
+ if (episode.ignored < DIALOG_ANSWER_IGNORED_MAX || episode.reported)
5861
+ return;
5862
+ episode.reported = true;
5863
+ // The time-based "parked" report would say the same thing again a minute later.
5864
+ if (this.dialogParkedKey === Daemon.dialogKey(dialog))
5865
+ this.dialogParkedReported = true;
5866
+ const holdsDeliveries = dialog.blocksDelivery === true || dialog.holdOnly === true;
5867
+ this.logger.warn({ dialog: dialog.description, attempts: episode.ignored, holdsDeliveries }, "Runtime dialog is ignoring AgEnD's answer — reporting for a human");
5868
+ this.emit("dialog_answer_ignored", { name: this.name, description: dialog.description, attempts: episode.ignored, holdsDeliveries });
5869
+ }
5719
5870
  /**
5720
5871
  * Remember how long a blocking dialog has been on screen. Once it has
5721
5872
  * outlived DIALOG_PARKED_NOTIFY_MS — the auto-dismiss did not take, or the
@@ -7596,6 +7747,7 @@ export class Daemon extends EventEmitter {
7596
7747
  * into exactly the window this exists to close.
7597
7748
  */
7598
7749
  beginSpawn() {
7750
+ this.dialogAnswers = null;
7599
7751
  // A relaunch settles any outstanding quit watch: the CLI came back.
7600
7752
  this.clearQuitRelaunchWatch();
7601
7753
  // A restarted CLI has no trustworthy turn edge for the process it replaced.
@@ -8171,6 +8323,7 @@ export class Daemon extends EventEmitter {
8171
8323
  catch { /* non-fatal */ }
8172
8324
  }
8173
8325
  async trySpawnInsideGate(reuseWindow = false, startupTimeoutMs) {
8326
+ const launchFence = this.launchFenceEpoch;
8174
8327
  const backendConfig = this.buildBackendConfig();
8175
8328
  // Compare freshly-built instructions against the last value the agent was
8176
8329
  // told about. Computed for ALL backends (not gated by
@@ -8206,6 +8359,16 @@ export class Daemon extends EventEmitter {
8206
8359
  this.pendingInstructionsUpdate = backendConfig.instructions;
8207
8360
  }
8208
8361
  }
8362
+ // A backend that has to ask its CLI something before the launch command exists (OpenCode: does
8363
+ // this binary take --auto?) does it here, off the event loop, bounded, and never fails the launch.
8364
+ try {
8365
+ await this.backend.prepareLaunch?.();
8366
+ }
8367
+ catch { /* unknown capability → the backend's conservative form */ }
8368
+ // Thrown, not `false`: a false verdict reads as "the CLI failed to start" and sends the startup path
8369
+ // into its resume-retry / set-the-session-aside handling, for an instance somebody just stopped.
8370
+ if (launchFence !== this.launchFenceEpoch)
8371
+ throw new Error("Launch cancelled: the instance was stopped or paused while the launch was being prepared");
8209
8372
  this.backend.writeConfig(backendConfig);
8210
8373
  this.backend.preTrust?.(this.config.working_directory);
8211
8374
  // Resolve working directory (e.g. symlink for hidden paths)