@songsid/agend 2.1.1-beta.2 → 2.1.1-beta.21

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 (156) hide show
  1. package/README.md +1 -0
  2. package/README.zh-TW.md +1 -0
  3. package/dist/access-path.js +15 -6
  4. package/dist/access-path.js.map +1 -1
  5. package/dist/agent-cli-instructions.md +9 -0
  6. package/dist/backend/antigravity.d.ts +4 -1
  7. package/dist/backend/antigravity.js +24 -13
  8. package/dist/backend/antigravity.js.map +1 -1
  9. package/dist/backend/claude-code.d.ts +37 -0
  10. package/dist/backend/claude-code.js +47 -8
  11. package/dist/backend/claude-code.js.map +1 -1
  12. package/dist/backend/codex.d.ts +17 -0
  13. package/dist/backend/codex.js +279 -62
  14. package/dist/backend/codex.js.map +1 -1
  15. package/dist/backend/gemini-cli.js +4 -7
  16. package/dist/backend/gemini-cli.js.map +1 -1
  17. package/dist/backend/grok.d.ts +24 -0
  18. package/dist/backend/grok.js +57 -8
  19. package/dist/backend/grok.js.map +1 -1
  20. package/dist/backend/kiro.d.ts +27 -0
  21. package/dist/backend/kiro.js +96 -15
  22. package/dist/backend/kiro.js.map +1 -1
  23. package/dist/backend/opencode.js +4 -7
  24. package/dist/backend/opencode.js.map +1 -1
  25. package/dist/backend/types.d.ts +44 -0
  26. package/dist/backend/types.js +3 -3
  27. package/dist/backend/types.js.map +1 -1
  28. package/dist/channel/adapters/discord.d.ts +15 -0
  29. package/dist/channel/adapters/discord.js +94 -6
  30. package/dist/channel/adapters/discord.js.map +1 -1
  31. package/dist/channel/adapters/telegram.d.ts +21 -0
  32. package/dist/channel/adapters/telegram.js +132 -15
  33. package/dist/channel/adapters/telegram.js.map +1 -1
  34. package/dist/channel/ipc-bridge.d.ts +17 -1
  35. package/dist/channel/ipc-bridge.js +53 -17
  36. package/dist/channel/ipc-bridge.js.map +1 -1
  37. package/dist/channel/ipc-timeouts.d.ts +40 -0
  38. package/dist/channel/ipc-timeouts.js +58 -0
  39. package/dist/channel/ipc-timeouts.js.map +1 -0
  40. package/dist/channel/mcp-server.js +30 -14
  41. package/dist/channel/mcp-server.js.map +1 -1
  42. package/dist/channel/mcp-tools.js +28 -1
  43. package/dist/channel/mcp-tools.js.map +1 -1
  44. package/dist/channel/message-queue.d.ts +1 -0
  45. package/dist/channel/message-queue.js +49 -17
  46. package/dist/channel/message-queue.js.map +1 -1
  47. package/dist/channel/reconnect-backoff.d.ts +17 -0
  48. package/dist/channel/reconnect-backoff.js +21 -0
  49. package/dist/channel/reconnect-backoff.js.map +1 -0
  50. package/dist/channel/types.d.ts +29 -0
  51. package/dist/classic-channel-manager.js +1 -5
  52. package/dist/classic-channel-manager.js.map +1 -1
  53. package/dist/cli.js +148 -60
  54. package/dist/cli.js.map +1 -1
  55. package/dist/completion.d.ts +27 -0
  56. package/dist/completion.js +121 -0
  57. package/dist/completion.js.map +1 -0
  58. package/dist/config-validator.js +27 -0
  59. package/dist/config-validator.js.map +1 -1
  60. package/dist/config.js +6 -0
  61. package/dist/config.js.map +1 -1
  62. package/dist/cost-guard.d.ts +3 -1
  63. package/dist/cost-guard.js +3 -1
  64. package/dist/cost-guard.js.map +1 -1
  65. package/dist/daemon.d.ts +230 -13
  66. package/dist/daemon.js +975 -335
  67. package/dist/daemon.js.map +1 -1
  68. package/dist/event-log.d.ts +31 -0
  69. package/dist/event-log.js +96 -0
  70. package/dist/event-log.js.map +1 -1
  71. package/dist/fleet-context.d.ts +2 -0
  72. package/dist/fleet-manager.d.ts +209 -4
  73. package/dist/fleet-manager.js +813 -84
  74. package/dist/fleet-manager.js.map +1 -1
  75. package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +22 -0
  76. package/dist/general-knowledge/skills/model-discovery/SKILL.md +14 -23
  77. package/dist/general-knowledge/skills/session-management/SKILL.md +15 -34
  78. package/dist/hang-detector.d.ts +19 -13
  79. package/dist/hang-detector.js +19 -49
  80. package/dist/hang-detector.js.map +1 -1
  81. package/dist/instance-lifecycle.d.ts +21 -1
  82. package/dist/instance-lifecycle.js +141 -7
  83. package/dist/instance-lifecycle.js.map +1 -1
  84. package/dist/instructions.d.ts +5 -0
  85. package/dist/instructions.js +10 -5
  86. package/dist/instructions.js.map +1 -1
  87. package/dist/locale.js +3 -0
  88. package/dist/locale.js.map +1 -1
  89. package/dist/logger.js +14 -0
  90. package/dist/logger.js.map +1 -1
  91. package/dist/mcp-liveness.d.ts +21 -0
  92. package/dist/mcp-liveness.js +27 -0
  93. package/dist/mcp-liveness.js.map +1 -0
  94. package/dist/outbound-handlers.d.ts +16 -0
  95. package/dist/outbound-handlers.js +121 -27
  96. package/dist/outbound-handlers.js.map +1 -1
  97. package/dist/outbound-schemas.d.ts +7 -2
  98. package/dist/outbound-schemas.js +13 -2
  99. package/dist/outbound-schemas.js.map +1 -1
  100. package/dist/pane-write-lock.d.ts +48 -0
  101. package/dist/pane-write-lock.js +73 -0
  102. package/dist/pane-write-lock.js.map +1 -0
  103. package/dist/process-memory.d.ts +31 -0
  104. package/dist/process-memory.js +79 -0
  105. package/dist/process-memory.js.map +1 -0
  106. package/dist/quickstart.js +17 -16
  107. package/dist/quickstart.js.map +1 -1
  108. package/dist/scheduler/db.js +3 -0
  109. package/dist/scheduler/db.js.map +1 -1
  110. package/dist/sd-notify.d.ts +45 -0
  111. package/dist/sd-notify.js +74 -3
  112. package/dist/sd-notify.js.map +1 -1
  113. package/dist/secret-file.d.ts +33 -0
  114. package/dist/secret-file.js +36 -0
  115. package/dist/secret-file.js.map +1 -0
  116. package/dist/setup-wizard.js +9 -7
  117. package/dist/setup-wizard.js.map +1 -1
  118. package/dist/tmux-control.d.ts +58 -5
  119. package/dist/tmux-control.js +107 -13
  120. package/dist/tmux-control.js.map +1 -1
  121. package/dist/tmux-manager.d.ts +47 -2
  122. package/dist/tmux-manager.js +103 -8
  123. package/dist/tmux-manager.js.map +1 -1
  124. package/dist/topic-commands.d.ts +43 -1
  125. package/dist/topic-commands.js +196 -66
  126. package/dist/topic-commands.js.map +1 -1
  127. package/dist/tui-glyphs.d.ts +25 -0
  128. package/dist/tui-glyphs.js +26 -0
  129. package/dist/tui-glyphs.js.map +1 -0
  130. package/dist/types.d.ts +19 -0
  131. package/dist/ui/view.html +291 -33
  132. package/dist/update-check.d.ts +22 -0
  133. package/dist/update-check.js +44 -0
  134. package/dist/update-check.js.map +1 -0
  135. package/dist/usage/providers.d.ts +50 -0
  136. package/dist/usage/providers.js +611 -0
  137. package/dist/usage/providers.js.map +1 -0
  138. package/dist/usage/usage-api.d.ts +50 -0
  139. package/dist/usage/usage-api.js +111 -0
  140. package/dist/usage/usage-api.js.map +1 -0
  141. package/dist/view-api.d.ts +10 -0
  142. package/dist/view-api.js +67 -14
  143. package/dist/view-api.js.map +1 -1
  144. package/dist/web-api.js +5 -2
  145. package/dist/web-api.js.map +1 -1
  146. package/package.json +4 -1
  147. package/templates/systemd.service.ejs +9 -1
  148. package/dist/channel/tool-tracker.d.ts +0 -13
  149. package/dist/channel/tool-tracker.js +0 -58
  150. package/dist/channel/tool-tracker.js.map +0 -1
  151. package/dist/daemon-entry.d.ts +0 -1
  152. package/dist/daemon-entry.js +0 -30
  153. package/dist/daemon-entry.js.map +0 -1
  154. package/dist/fleet-system-prompt.d.ts +0 -11
  155. package/dist/fleet-system-prompt.js +0 -61
  156. package/dist/fleet-system-prompt.js.map +0 -1
@@ -0,0 +1,22 @@
1
+ ---
2
+ name: cross-instance-messaging
3
+ description: Fire-and-queue cross-instance tools — send once, never resend on queued
4
+ ---
5
+
6
+ ## How to send
7
+
8
+ Use fleet tools only (`send_to_instance`, `delegate_task`, `request_information`, `report_result`, `broadcast`):
9
+ - Call returns immediately: `{ sent: true, queued: true }` — success, fleet owns delivery.
10
+ - **Do not wait** for the target to go idle; **do not** treat 30s IPC timeout as failure to re-send.
11
+ - **Error only if the target does not exist** (or similar hard reject) — then fix the name, don't spam.
12
+ - **Never re-send because the reply said `queued`** — that means the message is already queued.
13
+
14
+ ## requires_reply
15
+
16
+ - Means “target should later answer with `report_result` / a real reply”
17
+ - **Not** a synchronous wait for their turn to finish
18
+
19
+ ## Task flow
20
+
21
+ - `delegate_task` → silent work → `report_result` (zero ack-only pings)
22
+ - Cross-instance traffic is `[from:name]` → answer with `send_to_instance` / `report_result`, never `reply`
@@ -1,36 +1,27 @@
1
1
  ---
2
2
  name: model-discovery
3
- description: List available models per backend, configure model in fleet.yaml
3
+ description: Set and discover models pass-through to the CLI, no AgEnD allowlist gate
4
4
  ---
5
5
 
6
- ## Model Names by Backend
6
+ ## How to set a model
7
7
 
8
- Models are specified in fleet.yaml `defaults.model` or per-instance `model` field.
8
+ - fleet.yaml: `defaults.model` or per-instance `model`
9
+ - **Pass-through:** AgEnD no longer blocks unknown model ids — it may **warn**, then still pass the string to the CLI
10
+ - **CLI is source of truth** — if the model is invalid, the backend CLI errors (fix the name there)
9
11
 
10
- | Backend | How to list models | Default |
11
- |---------|-------------------|---------|
12
- | **kiro-cli** | In tmux: send `/model` + Enter → read model list → Esc to close | auto (latest) |
13
- | **claude-code** | `sonnet`, `opus`, `haiku`, `opusplan`, `best`, `sonnet[1m]`, `opus[1m]` | sonnet |
14
- | **antigravity** | Run `agy models` to see available models | Gemini 3.5 Flash (Medium) |
15
- | **codex** | `gpt-4o`, `o3`, `o4-mini` | gpt-4o |
16
- | **opencode** | `opencode models` | depends on provider |
17
- | **grok** | `grok-4.5`, `grok-4.3`, `grok-code`, `grok-build-0.1` | grok default |
12
+ ## Discover real names
18
13
 
19
- **Important for antigravity (agy):**
20
- - `agy models` shows names like `Gemini 3.5 Flash (Medium)` — the parenthetical suffix (Medium/High/Low/Thinking) is the **effort level**, NOT part of the model name.
21
- - When setting model in fleet.yaml, use only the base name WITHOUT the effort suffix.
22
- - Example: `agy models` shows `Gemini 3.5 Flash (Medium)` set `model: "Gemini 3.5 Flash"`
23
- - Example: `agy models` shows `Claude Opus 4.6 (Thinking)` → set `model: "Claude Opus 4.6"`
14
+ | Backend | How |
15
+ |---------|-----|
16
+ | kiro-cli | In pane: `/model` (gpt-*, deepseek-*, minimax-*, glm-*, qwen* supported) |
17
+ | claude-code | `sonnet` / `opus` / `haiku` / `opusplan` / `Fable` / aliases |
18
+ | codex | pane `/model` or docs (`gpt-*`, `o*`) |
19
+ | grok | `grok models` |
20
+ | antigravity | `agy models` — set **base name only** (drop `(Medium)` / `(Thinking)` effort suffix) |
21
+ | opencode | `opencode models` |
24
22
 
25
- **Important:** Model names vary by backend. Always check the actual CLI output rather than guessing names.
26
-
27
- Example fleet.yaml:
28
23
  ```yaml
29
24
  defaults:
30
25
  backend: kiro-cli
31
26
  model: claude-sonnet-4-20250514
32
-
33
- instances:
34
- heavy-task:
35
- model: claude-opus-4-20250514
36
27
  ```
@@ -1,46 +1,27 @@
1
1
  ---
2
2
  name: session-management
3
- description: Where kiro-cli and claude-code store sessions, and how to fork one to a new instance
3
+ description: Session stores, forking, and auth-pause recovery
4
4
  ---
5
5
 
6
+ ## Auth failure (auto-pause)
7
+
8
+ When AgEnD sees `auth_error` it **pauses** that instance (`pausePending` sticky):
9
+ - One notification **per backend** (not per instance) — log in once, same-backend peers recover.
10
+ - Auth is **per-user global** across backends: claude-code, codex, kiro, grok, opencode, antigravity.
11
+ - Messages while paused stay in the **queue** — do not re-send.
12
+ - After the user re-auths: `wake` / normal wake clears `pausePending`.
13
+
6
14
  ## Where sessions live
7
15
 
8
16
  | | kiro-cli | claude-code |
9
17
  |---|---|---|
10
18
  | Store | `~/.kiro/sessions/cli/<uuid>.json` | `~/.claude/projects/<path-encoded>/*.jsonl` |
11
- | Keyed by | session uuid | project (working) directory |
12
- | Reload | `/chat load <file>` | none — `--continue` (latest for the dir) / `--resume <id>` |
13
- | Text export | — | `/export` (plain text, **not** reloadable) |
14
-
15
- `<path-encoded>` = the absolute working_directory with `/` → `-`
16
- (e.g. `/home/han/Projects/AgEnD` → `-home-han-Projects-AgEnD`).
17
-
18
- ## Fork a session to a new instance
19
-
20
- Confirm the source is **idle** first (`describe_instance` / `get_fleet_status`) — don't fork mid-task.
21
-
22
- **kiro-cli** — save, copy, load:
23
- 1. On the source, save: `/chat save <name>.json -f` (paste via tmux if needed).
24
- 2. `create_instance` (same backend).
25
- 3. `cp ~/.agend/workspaces/<source>/<name>.json ~/.agend/workspaces/<target>/`
26
- 4. Load on the target: `/chat load <name>.json`, or set `pre_task_command: "/chat load <name>.json"`.
27
-
28
- **claude-code** — copy the `.jsonl` (no save/load command):
29
- 1. Newest source session: `ls -lt ~/.claude/projects/<source-encoded>/*.jsonl | head`
30
- 2. `create_instance` (backend `claude-code`); note its working_directory.
31
- 3. Copy into the target's encoded project dir:
32
- ```bash
33
- TARGET_ENC="$(echo '<target-working-dir>' | sed 's#/#-#g')"
34
- mkdir -p ~/.claude/projects/$TARGET_ENC
35
- cp ~/.claude/projects/<source-encoded>/<session>.jsonl ~/.claude/projects/$TARGET_ENC/
36
- ```
37
- 4. Start the target — claude-code resumes the newest `.jsonl` via `--continue`.
19
+ | Reload | `/chat load <file>` | `--continue` / `--resume <id>` |
38
20
 
39
- **Caveats:** a claude-code session only truly makes sense under its original working_directory
40
- (paths inside the transcript refer to it). Pick the right `.jsonl` if several exist (newest by
41
- mtime; compaction/branches create new files). `/export` is text only, not reloadable.
21
+ `<path-encoded>` = absolute cwd with `/` `-`.
42
22
 
43
- ## Backup
23
+ ## Fork (source must be idle)
44
24
 
45
- Sessions are plain files back them up by copying the store paths above
46
- (e.g. `cp ~/.kiro/sessions/cli/*.json <dest>/`). Only copy while the instance is idle.
25
+ - **kiro:** `/chat save name.json -f` `create_instance` copy workspace file `/chat load name.json`
26
+ - **claude-code:** copy newest `*.jsonl` into target's encoded project dir start (uses `--continue`)
27
+ - Prefer `replace_instance` when the whole session is poisoned (see instance-lifecycle)
@@ -1,16 +1,22 @@
1
1
  import { EventEmitter } from "node:events";
2
+ /**
3
+ * Carries "this instance looks hung" from the daemon to the fleet manager.
4
+ *
5
+ * It is only an event bridge. Hang detection itself lives in the daemon's pane-state
6
+ * machine, which emits `hang` directly when a pane stops changing for the configured
7
+ * stuck timeout; `instance-lifecycle` subscribes to that and notifies.
8
+ *
9
+ * It used to also contain a silence-timer state machine — `start()`, `isHung()`,
10
+ * `hungEmitted`, and timestamps fed by `recordActivity` / `recordInbound` /
11
+ * `recordStatuslineUpdate`. None of it ran: `start()` was never called from
12
+ * anywhere, so `isHung()` was unreachable, the timestamps were written and never
13
+ * read, and the constructor's `timeoutMinutes` was ignored (the real stuck timeout
14
+ * is read separately by the pane monitor). Its tests exercised that dead logic —
15
+ * two had identical setup with contradictory expectations, which can only pass
16
+ * unnoticed when neither runs against anything real.
17
+ *
18
+ * Kept as a named class rather than a bare EventEmitter so the daemon → lifecycle
19
+ * wiring stays typed and greppable.
20
+ */
2
21
  export declare class HangDetector extends EventEmitter {
3
- private lastActivityTs;
4
- private lastStatuslineTs;
5
- private lastInboundTs;
6
- private hungEmitted;
7
- private checkTimer;
8
- private timeoutMs;
9
- constructor(timeoutMinutes: number);
10
- recordActivity(): void;
11
- recordInbound(): void;
12
- recordStatuslineUpdate(): void;
13
- isHung(): boolean;
14
- start(intervalMs?: number): void;
15
- stop(): void;
16
22
  }
@@ -1,53 +1,23 @@
1
1
  import { EventEmitter } from "node:events";
2
+ /**
3
+ * Carries "this instance looks hung" from the daemon to the fleet manager.
4
+ *
5
+ * It is only an event bridge. Hang detection itself lives in the daemon's pane-state
6
+ * machine, which emits `hang` directly when a pane stops changing for the configured
7
+ * stuck timeout; `instance-lifecycle` subscribes to that and notifies.
8
+ *
9
+ * It used to also contain a silence-timer state machine — `start()`, `isHung()`,
10
+ * `hungEmitted`, and timestamps fed by `recordActivity` / `recordInbound` /
11
+ * `recordStatuslineUpdate`. None of it ran: `start()` was never called from
12
+ * anywhere, so `isHung()` was unreachable, the timestamps were written and never
13
+ * read, and the constructor's `timeoutMinutes` was ignored (the real stuck timeout
14
+ * is read separately by the pane monitor). Its tests exercised that dead logic —
15
+ * two had identical setup with contradictory expectations, which can only pass
16
+ * unnoticed when neither runs against anything real.
17
+ *
18
+ * Kept as a named class rather than a bare EventEmitter so the daemon → lifecycle
19
+ * wiring stays typed and greppable.
20
+ */
2
21
  export class HangDetector extends EventEmitter {
3
- lastActivityTs = 0;
4
- lastStatuslineTs = 0;
5
- lastInboundTs = 0;
6
- hungEmitted = false;
7
- checkTimer = null;
8
- timeoutMs;
9
- constructor(timeoutMinutes) {
10
- super();
11
- this.timeoutMs = timeoutMinutes * 60 * 1000;
12
- }
13
- recordActivity() {
14
- this.lastActivityTs = Date.now();
15
- if (this.hungEmitted) {
16
- this.hungEmitted = false;
17
- }
18
- }
19
- recordInbound() {
20
- this.lastInboundTs = Date.now();
21
- }
22
- recordStatuslineUpdate() {
23
- this.lastStatuslineTs = Date.now();
24
- }
25
- isHung() {
26
- if (this.lastActivityTs === 0)
27
- return false;
28
- if (this.lastInboundTs === 0)
29
- return false;
30
- const now = Date.now();
31
- // Only flag as hung if:
32
- // 1. There's an inbound that hasn't been answered (no activity since inbound)
33
- // 2. Timeout has elapsed since that inbound
34
- const noActivitySinceInbound = this.lastActivityTs < this.lastInboundTs;
35
- const stale = now - this.lastInboundTs > this.timeoutMs;
36
- return stale && noActivitySinceInbound;
37
- }
38
- start(intervalMs = 60_000) {
39
- this.checkTimer = setInterval(() => {
40
- if (this.isHung() && !this.hungEmitted) {
41
- this.hungEmitted = true;
42
- this.emit("hang");
43
- }
44
- }, intervalMs);
45
- }
46
- stop() {
47
- if (this.checkTimer) {
48
- clearInterval(this.checkTimer);
49
- this.checkTimer = null;
50
- }
51
- }
52
22
  }
53
23
  //# sourceMappingURL=hang-detector.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"hang-detector.js","sourceRoot":"","sources":["../src/hang-detector.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAE3C,MAAM,OAAO,YAAa,SAAQ,YAAY;IACpC,cAAc,GAAG,CAAC,CAAC;IACnB,gBAAgB,GAAG,CAAC,CAAC;IACrB,aAAa,GAAG,CAAC,CAAC;IAClB,WAAW,GAAG,KAAK,CAAC;IACpB,UAAU,GAA0C,IAAI,CAAC;IACzD,SAAS,CAAS;IAE1B,YAAY,cAAsB;QAChC,KAAK,EAAE,CAAC;QACR,IAAI,CAAC,SAAS,GAAG,cAAc,GAAG,EAAE,GAAG,IAAI,CAAC;IAC9C,CAAC;IAED,cAAc;QACZ,IAAI,CAAC,cAAc,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;QACjC,IAAI,IAAI,CAAC,WAAW,EAAE,CAAC;YACrB,IAAI,CAAC,WAAW,GAAG,KAAK,CAAC;QAC3B,CAAC;IACH,CAAC;IAED,aAAa;QACX,IAAI,CAAC,aAAa,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;IAClC,CAAC;IAED,sBAAsB;QACpB,IAAI,CAAC,gBAAgB,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;IACrC,CAAC;IAED,MAAM;QACJ,IAAI,IAAI,CAAC,cAAc,KAAK,CAAC;YAAE,OAAO,KAAK,CAAC;QAC5C,IAAI,IAAI,CAAC,aAAa,KAAK,CAAC;YAAE,OAAO,KAAK,CAAC;QAC3C,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;QACvB,wBAAwB;QACxB,8EAA8E;QAC9E,4CAA4C;QAC5C,MAAM,sBAAsB,GAAG,IAAI,CAAC,cAAc,GAAG,IAAI,CAAC,aAAa,CAAC;QACxE,MAAM,KAAK,GAAG,GAAG,GAAG,IAAI,CAAC,aAAa,GAAG,IAAI,CAAC,SAAS,CAAC;QACxD,OAAO,KAAK,IAAI,sBAAsB,CAAC;IACzC,CAAC;IAED,KAAK,CAAC,UAAU,GAAG,MAAM;QACvB,IAAI,CAAC,UAAU,GAAG,WAAW,CAAC,GAAG,EAAE;YACjC,IAAI,IAAI,CAAC,MAAM,EAAE,IAAI,CAAC,IAAI,CAAC,WAAW,EAAE,CAAC;gBACvC,IAAI,CAAC,WAAW,GAAG,IAAI,CAAC;gBACxB,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;YACpB,CAAC;QACH,CAAC,EAAE,UAAU,CAAC,CAAC;IACjB,CAAC;IAED,IAAI;QACF,IAAI,IAAI,CAAC,UAAU,EAAE,CAAC;YACpB,aAAa,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;YAC/B,IAAI,CAAC,UAAU,GAAG,IAAI,CAAC;QACzB,CAAC;IACH,CAAC;CACF"}
1
+ {"version":3,"file":"hang-detector.js","sourceRoot":"","sources":["../src/hang-detector.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAE3C;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,OAAO,YAAa,SAAQ,YAAY;CAAG"}
@@ -4,6 +4,7 @@ import type { Logger } from "./logger.js";
4
4
  import type { IpcClient } from "./channel/ipc-bridge.js";
5
5
  import type { EventLog } from "./event-log.js";
6
6
  import type { TmuxControlClient } from "./tmux-control.js";
7
+ import type { FleetInstructionsParams } from "./instructions.js";
7
8
  export interface BackendInstallationInfo {
8
9
  binary: string;
9
10
  install: string;
@@ -12,6 +13,14 @@ export interface BackendInstallationInfo {
12
13
  export declare const BACKEND_INSTALLATION_INFO: Readonly<Record<string, BackendInstallationInfo>>;
13
14
  /** Check one executable using the same PATH visible to the fleet process. */
14
15
  export declare function checkBinaryInstalled(binary: string): boolean;
16
+ /** Whether a process command line identifies the shared AgEnD fleet process. */
17
+ export declare function isFleetStartCommandLine(commandLine: string): boolean;
18
+ /**
19
+ * Return why an instance daemon PID must not receive SIGTERM, or null when the
20
+ * stale-process cleanup may proceed. daemon.pid historically stores the shared
21
+ * in-process FleetManager PID, so this check is deliberately conservative.
22
+ */
23
+ export declare function getUnsafeInstanceDaemonPidReason(pid: number, dataDir: string): string | null;
15
24
  /**
16
25
  * Context interface for instance lifecycle operations.
17
26
  * FleetManager implements this.
@@ -90,8 +99,19 @@ export declare class InstanceLifecycle {
90
99
  private ctx;
91
100
  /** Active daemon processes: instanceName → Daemon */
92
101
  readonly daemons: Map<string, import("./daemon.js").Daemon>;
102
+ /** backend → last auth-error alert time, so one expiry sends one alert. */
103
+ private lastAuthAlertAt;
93
104
  constructor(ctx: LifecycleContext);
94
- start(name: string, config: InstanceConfig, topicMode: boolean): Promise<void>;
105
+ /** Backend a running instance uses (config fleet default). */
106
+ private backendOf;
107
+ /**
108
+ * One alert per backend per cooldown, naming every affected instance — a CLI's
109
+ * credentials are shared, so N instances failing is ONE problem with ONE fix
110
+ * (re-login once). The per-instance daemon cooldown can't dedupe across
111
+ * instances, so the fleet-level map does it here.
112
+ */
113
+ private notifyAuthErrorOnce;
114
+ start(name: string, config: InstanceConfig, topicMode: boolean, runtimeIdentity?: FleetInstructionsParams["runtimeIdentity"]): Promise<void>;
95
115
  isPaused(name: string): boolean;
96
116
  getLastPausedAt(name: string): number | null;
97
117
  pause(name: string): Promise<void>;
@@ -29,14 +29,93 @@ export function checkBinaryInstalled(binary) {
29
29
  return false;
30
30
  }
31
31
  }
32
+ /** Whether a process command line identifies the shared AgEnD fleet process. */
33
+ export function isFleetStartCommandLine(commandLine) {
34
+ const normalized = commandLine.replace(/\0/g, " ").replace(/\s+/g, " ").trim();
35
+ return /\b(?:agend|(?:cli|daemon-entry)\.(?:js|ts))\b.*\bfleet\s+start\b/i.test(normalized);
36
+ }
37
+ function readProcessCommandLine(pid) {
38
+ try {
39
+ return readFileSync(`/proc/${pid}/cmdline`, "utf8").replace(/\0/g, " ").trim();
40
+ }
41
+ catch {
42
+ try {
43
+ return execFileSync("ps", ["-p", String(pid), "-o", "command="], {
44
+ encoding: "utf8",
45
+ stdio: ["ignore", "pipe", "ignore"],
46
+ timeout: 1000,
47
+ }).trim();
48
+ }
49
+ catch {
50
+ return "";
51
+ }
52
+ }
53
+ }
54
+ /**
55
+ * Return why an instance daemon PID must not receive SIGTERM, or null when the
56
+ * stale-process cleanup may proceed. daemon.pid historically stores the shared
57
+ * in-process FleetManager PID, so this check is deliberately conservative.
58
+ */
59
+ export function getUnsafeInstanceDaemonPidReason(pid, dataDir) {
60
+ if (!Number.isSafeInteger(pid) || pid <= 1)
61
+ return "invalid or privileged PID";
62
+ if (pid === process.pid)
63
+ return "PID is the current shared fleet process";
64
+ try {
65
+ const fleetPid = Number.parseInt(readFileSync(join(dataDir, "fleet.pid"), "utf8").trim(), 10);
66
+ if (Number.isSafeInteger(fleetPid) && pid === fleetPid) {
67
+ return "PID matches fleet.pid";
68
+ }
69
+ }
70
+ catch {
71
+ // Missing/unreadable fleet.pid: fall through to the process command check.
72
+ }
73
+ const commandLine = readProcessCommandLine(pid);
74
+ if (commandLine && isFleetStartCommandLine(commandLine)) {
75
+ return "process command line is agend fleet start";
76
+ }
77
+ return null;
78
+ }
79
+ /** Suppress duplicate auth alerts for the same backend within this window. */
80
+ const AUTH_ALERT_COOLDOWN_MS = 5 * 60_000;
32
81
  export class InstanceLifecycle {
33
82
  ctx;
34
83
  /** Active daemon processes: instanceName → Daemon */
35
84
  daemons = new Map();
85
+ /** backend → last auth-error alert time, so one expiry sends one alert. */
86
+ lastAuthAlertAt = new Map();
36
87
  constructor(ctx) {
37
88
  this.ctx = ctx;
38
89
  }
39
- async start(name, config, topicMode) {
90
+ /** Backend a running instance uses (config → fleet default). */
91
+ backendOf(name) {
92
+ return this.ctx.fleetConfig?.instances[name]?.backend
93
+ ?? this.ctx.fleetConfig?.defaults?.backend
94
+ ?? "claude-code";
95
+ }
96
+ /**
97
+ * One alert per backend per cooldown, naming every affected instance — a CLI's
98
+ * credentials are shared, so N instances failing is ONE problem with ONE fix
99
+ * (re-login once). The per-instance daemon cooldown can't dedupe across
100
+ * instances, so the fleet-level map does it here.
101
+ */
102
+ notifyAuthErrorOnce(name, message) {
103
+ const backend = this.backendOf(name);
104
+ const now = Date.now();
105
+ const last = this.lastAuthAlertAt.get(backend) ?? 0;
106
+ if (now - last < AUTH_ALERT_COOLDOWN_MS) {
107
+ this.ctx.logger.info({ name, backend }, "auth error suppressed (backend already alerted)");
108
+ return;
109
+ }
110
+ this.lastAuthAlertAt.set(backend, now);
111
+ const affected = [...this.daemons.keys()].filter(n => this.backendOf(n) === backend);
112
+ const others = affected.filter(n => n !== name);
113
+ const scope = others.length
114
+ ? `${affected.length} instances on \`${backend}\`: ${affected.join(", ")}`
115
+ : `\`${name}\` (${backend})`;
116
+ this.ctx.notifyInstanceTopic(name, `🔑 ${message}\n\nAffects ${scope}. Credentials are shared per backend — one re-login restores all of them; affected instances pause until then.`);
117
+ }
118
+ async start(name, config, topicMode, runtimeIdentity) {
40
119
  if (this.daemons.has(name)) {
41
120
  this.ctx.logger.info({ name }, "Instance already running, skipping");
42
121
  return;
@@ -69,7 +148,11 @@ export class InstanceLifecycle {
69
148
  return;
70
149
  }
71
150
  const backend = createBackend(backendName, instanceDir);
72
- const daemon = new Daemon(name, config, instanceDir, topicMode, backend, this.ctx.controlClient ?? undefined, this.ctx.logger);
151
+ const daemon = new Daemon(name, config, instanceDir, topicMode, backend, this.ctx.controlClient ?? undefined, this.ctx.logger, runtimeIdentity ?? {
152
+ kind: "fleet-topic",
153
+ backend: backendName,
154
+ model: config.model ?? "default",
155
+ });
73
156
  // Catch errors from daemon internals (e.g. IPC server) to prevent crashing the fleet process
74
157
  daemon.on("error", (err) => {
75
158
  this.ctx.logger.error({ err, name }, "Daemon emitted error — instance isolated");
@@ -127,17 +210,51 @@ export class InstanceLifecycle {
127
210
  this.ctx.eventLog?.insert(name, "snapshot_failed", {});
128
211
  this.ctx.notifyInstanceTopic(name, t("inst.restarted_no_context", name));
129
212
  }, this.ctx.logger, `daemon.snapshot_failed[${name}]`));
213
+ daemon.on("supervision_ended", safeHandler((data) => {
214
+ // The instance is dead and nothing will restart it. Say so where the operator
215
+ // is looking, and mark the topic — otherwise messages routed here just queue
216
+ // or fail with a bare ❌ and the dashboard still looks normal.
217
+ this.ctx.eventLog?.insert(name, "supervision_ended", { reason: data.reason });
218
+ this.ctx.logger.error({ name, reason: data.reason }, "Instance is no longer supervised");
219
+ this.ctx.notifyInstanceTopic(name, `🛑 ${name} is no longer running and will not be restarted automatically — ${data.reason}.\n${data.remedy}`);
220
+ this.ctx.setTopicIcon(name, "red");
221
+ }, this.ctx.logger, `daemon.supervision_ended[${name}]`));
222
+ daemon.on("health_check_error", safeHandler((data) => {
223
+ this.ctx.eventLog?.insert(name, "health_check_error", { message: data.message });
224
+ this.ctx.logger.error({ name, message: data.message }, "Health check failing — instance supervision degraded");
225
+ this.ctx.notifyInstanceTopic(name, `⚠️ ${name}: health check is failing (\`${data.message}\`). Crash detection for this instance may be degraded — see daemon.log.`);
226
+ }, this.ctx.logger, `daemon.health_check_error[${name}]`));
130
227
  daemon.on("crash_loop", safeHandler(() => {
131
228
  this.ctx.eventLog?.insert(name, "crash_loop", {});
132
229
  this.ctx.logger.error({ name }, "Instance in crash loop — respawn paused");
133
230
  this.ctx.notifyInstanceTopic(name, t("inst.respawn_paused", name));
134
231
  this.ctx.setTopicIcon(name, "red");
135
232
  }, this.ctx.logger, `daemon.crash_loop[${name}]`));
233
+ daemon.on("mcp_died", safeHandler((data) => {
234
+ this.ctx.eventLog?.insert(name, "mcp_died", { pid: data.pid });
235
+ this.ctx.logger.error({ name, pid: data.pid }, "MCP server died — instance cannot use agend tools");
236
+ this.ctx.webhookEmit("mcp_died", name, { pid: data.pid });
237
+ // The CLI owns the MCP server's stdio pipes, so only restarting the CLI can
238
+ // restore its tools — say so instead of implying self-healing. Deliberately
239
+ // NOT auto-restarting: that would interrupt whatever the agent is doing, and
240
+ // an instance whose CLI is otherwise fine may still be doing useful work.
241
+ this.ctx.notifyInstanceTopic(name, `⚠️ \`${name}\` 的 MCP server 已終止 — 這個 instance 目前無法使用 agend 工具(無法 reply / 跨 instance 通訊)。\n`
242
+ + `CLI 本身還在執行。工具只能由 CLI 自己重新啟動 MCP server,請用 \`restart_instance("${name}")\` 或 \`/restart\` 恢復。`);
243
+ }, this.ctx.logger, `daemon.mcp_died[${name}]`));
136
244
  daemon.on("pty_error", safeHandler((data) => {
137
245
  this.ctx.eventLog?.insert(name, "pty_error", { type: data.type, action: data.action });
138
246
  this.ctx.logger.warn({ name, errorType: data.type, action: data.action }, `PTY error: ${data.message}`);
139
247
  const emoji = data.type === "rate_limit" || data.type === "timeout" ? "⏳" : data.type === "auth_error" ? "🔑" : "⚠️";
140
- this.ctx.notifyInstanceTopic(name, t("inst.notification", emoji, name, data.message, data.action));
248
+ // Auth failures are a property of the BACKEND's shared credentials, not of
249
+ // one instance: every instance on that CLI fails at once, and one re-login
250
+ // fixes them all. Notify once per backend (listing who's affected) instead
251
+ // of N near-identical alerts, and suppress repeats fleet-wide.
252
+ if (data.type === "auth_error") {
253
+ this.notifyAuthErrorOnce(name, data.message);
254
+ }
255
+ else {
256
+ this.ctx.notifyInstanceTopic(name, t("inst.notification", emoji, name, data.message, data.action));
257
+ }
141
258
  this.ctx.webhookEmit("pty_error", name, { type: data.type, action: data.action, message: data.message });
142
259
  // The CLI interrupted itself on this error, so any pending Cancel button is
143
260
  // now useless — retire it. We only reach here when the error wasn't
@@ -154,6 +271,17 @@ export class InstanceLifecycle {
154
271
  // default (valid) model.
155
272
  this.ctx.restartSingleInstance(name, { freshStart: true }).catch(err => this.ctx.logger.error({ err, name }, "pty_error restart failed"));
156
273
  }
274
+ else if (data.action === "pause") {
275
+ // Previously unhandled, so an expired session kept receiving messages and
276
+ // re-sending its whole context into a CLI that could only fail — wasted
277
+ // credit and lost work. pause() alone no-ops while the pane is busy (the
278
+ // usual state when auth fails), so mark it to pause as soon as it idles.
279
+ // Queued messages survive: delivery wakes a paused instance.
280
+ void this.pause(name)
281
+ .catch(err => this.ctx.logger.warn({ err, name }, "auth-error pause failed"))
282
+ .finally(() => { if (!this.isPaused(name))
283
+ daemon.requestPauseWhenIdle(); });
284
+ }
157
285
  }, this.ctx.logger, `daemon.pty_error[${name}]`));
158
286
  daemon.on("pty_recovered", safeHandler((data) => {
159
287
  const mins = Math.floor(data.downtime_s / 60);
@@ -239,11 +367,17 @@ export class InstanceLifecycle {
239
367
  const pidPath = join(instanceDir, "daemon.pid");
240
368
  if (existsSync(pidPath)) {
241
369
  const pid = parseInt(readFileSync(pidPath, "utf-8").trim(), 10);
242
- try {
243
- process.kill(pid, "SIGTERM");
370
+ const unsafeReason = getUnsafeInstanceDaemonPidReason(pid, this.ctx.dataDir);
371
+ if (unsafeReason) {
372
+ this.ctx.logger.error({ instance: name, pid, reason: unsafeReason, pidPath }, `Refusing to SIGTERM pid ${pid} — it is the shared fleet process, not a per-instance daemon`);
244
373
  }
245
- catch (e) {
246
- this.ctx.logger.debug({ err: e, pid }, "SIGTERM failed for stale process");
374
+ else {
375
+ try {
376
+ process.kill(pid, "SIGTERM");
377
+ }
378
+ catch (e) {
379
+ this.ctx.logger.debug({ err: e, pid }, "SIGTERM failed for stale process");
380
+ }
247
381
  }
248
382
  }
249
383
  // Kill orphaned tmux window (daemon not in memory but window may persist)