@dsiloed/silo-link 1.19.15 → 1.19.16

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 (76) hide show
  1. package/README.md +8 -0
  2. package/dist/api/backend-health.d.ts +44 -0
  3. package/dist/api/backend-health.d.ts.map +1 -0
  4. package/dist/api/backend-health.js +80 -0
  5. package/dist/api/backend-health.js.map +1 -0
  6. package/dist/api/dsiloed-client.d.ts +7 -3
  7. package/dist/api/dsiloed-client.d.ts.map +1 -1
  8. package/dist/api/dsiloed-client.js +29 -4
  9. package/dist/api/dsiloed-client.js.map +1 -1
  10. package/dist/cable/subscription-manager.d.ts +26 -1
  11. package/dist/cable/subscription-manager.d.ts.map +1 -1
  12. package/dist/cable/subscription-manager.js +72 -13
  13. package/dist/cable/subscription-manager.js.map +1 -1
  14. package/dist/cli/claude-md-block.d.ts +1 -1
  15. package/dist/cli/claude-md-block.d.ts.map +1 -1
  16. package/dist/cli/claude-md-block.js +3 -1
  17. package/dist/cli/claude-md-block.js.map +1 -1
  18. package/dist/cli/file-log.d.ts +58 -3
  19. package/dist/cli/file-log.d.ts.map +1 -1
  20. package/dist/cli/file-log.js +110 -7
  21. package/dist/cli/file-log.js.map +1 -1
  22. package/dist/core/agent-launcher.d.ts +26 -0
  23. package/dist/core/agent-launcher.d.ts.map +1 -1
  24. package/dist/core/auto-updater.d.ts +11 -0
  25. package/dist/core/auto-updater.d.ts.map +1 -1
  26. package/dist/core/auto-updater.js +84 -15
  27. package/dist/core/auto-updater.js.map +1 -1
  28. package/dist/core/base-tmux-launcher.d.ts +66 -20
  29. package/dist/core/base-tmux-launcher.d.ts.map +1 -1
  30. package/dist/core/base-tmux-launcher.js +236 -76
  31. package/dist/core/base-tmux-launcher.js.map +1 -1
  32. package/dist/core/bridge.d.ts +28 -0
  33. package/dist/core/bridge.d.ts.map +1 -1
  34. package/dist/core/bridge.js +217 -25
  35. package/dist/core/bridge.js.map +1 -1
  36. package/dist/core/claude-launcher.d.ts +11 -4
  37. package/dist/core/claude-launcher.d.ts.map +1 -1
  38. package/dist/core/claude-launcher.js +59 -16
  39. package/dist/core/claude-launcher.js.map +1 -1
  40. package/dist/core/codex-launcher.d.ts +3 -3
  41. package/dist/core/codex-launcher.d.ts.map +1 -1
  42. package/dist/core/codex-launcher.js +9 -5
  43. package/dist/core/codex-launcher.js.map +1 -1
  44. package/dist/core/gemini-launcher.d.ts +3 -3
  45. package/dist/core/gemini-launcher.d.ts.map +1 -1
  46. package/dist/core/gemini-launcher.js +9 -5
  47. package/dist/core/gemini-launcher.js.map +1 -1
  48. package/dist/core/health-monitor.d.ts +87 -0
  49. package/dist/core/health-monitor.d.ts.map +1 -0
  50. package/dist/core/health-monitor.js +93 -0
  51. package/dist/core/health-monitor.js.map +1 -0
  52. package/dist/core/idle-completion.d.ts +38 -0
  53. package/dist/core/idle-completion.d.ts.map +1 -0
  54. package/dist/core/idle-completion.js +52 -0
  55. package/dist/core/idle-completion.js.map +1 -0
  56. package/dist/core/launch-binding.d.ts +52 -0
  57. package/dist/core/launch-binding.d.ts.map +1 -0
  58. package/dist/core/launch-binding.js +81 -0
  59. package/dist/core/launch-binding.js.map +1 -0
  60. package/dist/core/transcript-usage.d.ts +9 -0
  61. package/dist/core/transcript-usage.d.ts.map +1 -1
  62. package/dist/core/transcript-usage.js +19 -0
  63. package/dist/core/transcript-usage.js.map +1 -1
  64. package/dist/core/usage-ledger.d.ts +1 -1
  65. package/dist/core/usage-ledger.d.ts.map +1 -1
  66. package/dist/mcp/server.d.ts +4 -0
  67. package/dist/mcp/server.d.ts.map +1 -1
  68. package/dist/mcp/server.js +15 -0
  69. package/dist/mcp/server.js.map +1 -1
  70. package/dist/mcp/tools/register-tools.d.ts +2 -0
  71. package/dist/mcp/tools/register-tools.d.ts.map +1 -1
  72. package/dist/mcp/tools/register-tools.js +154 -36
  73. package/dist/mcp/tools/register-tools.js.map +1 -1
  74. package/dist/types/index.d.ts +6 -0
  75. package/dist/types/index.d.ts.map +1 -1
  76. package/package.json +3 -1
@@ -4,5 +4,5 @@
4
4
  *
5
5
  * Keep this block SHORT. It loads into every SiloLink-launched agent's context.
6
6
  */
7
- export declare const SILOLINK_CLAUDE_MD_BLOCK = "## Remote Communication (SiloLink)\n\nApplies only when launched via SiloLink (prompt contains `conversation_id` or tells you to `remote_register`). Ignore otherwise.\n\n- Register with `remote_register({ conversation_id })`.\n- Call `remote_load_context()` ONLY on a fresh session. Skip it when you were resumed \u2014 you already have the history.\n- All communication via `remote_notify` / `remote_ask`. Never print to the terminal; the user is in Harmoniq, not watching.\n- **@-mention discipline.** Your launch prompt has a `**Dispatched by:** @<DispatcherHandle>` line. ANY message that needs your dispatcher to act \u2014 mid-task questions, blockers, errors, requests for direction, AND your final completion \u2014 MUST start with that `@<DispatcherHandle>` **AND be posted to the report conv** (`mcp__silolink__remote_post({ conversation_id: <report_to_conversation_id> })`), not via `remote_notify` to the dispatch conv. Mention-dispatch only wakes the dispatcher's LlmAgent when the @-mention lands in a conversation they're a member of \u2014 the dispatch conv is per-session and they're typically not subscribed; the report conv is the channel they read. Routine progress notes (\"phase 2 done, starting tests\") still go via `remote_notify` (no @-mention needed).\n- Send a `remote_notify` after every major step (file edits, commits, merges, errors). Going silent = user blind.\n- Poll loop: `remote_poll()` blocks up to 30s server-side (longer blocks risk Claude killing the tool as hung \u2014 keep this value as-is). If `{pending:true}`, loop again. The poll loop is the **safety net**, not the primary exit path \u2014 see \"Self-kill when work is done\" below. After **~60 consecutive pending (~30 min idle)**, `remote_notify(\"Going idle\")`, `remote_unregister()`, exit.\n- Every polled message is new; the queue drops it on your next call. Never ignore one. `remote_load_context()` is history-only.\n- Prefer small/cheap models for mechanical work (running specs, PR creation, merges). Use one-shot sub-sessions when possible to keep the main polling session's context lean.\n\n### Two conversations are in play\n\nYou typically have TWO distinct conversation ids in your session:\n- **Dispatch conv** \u2014 the conv you `remote_register`'d on (e.g. `silolink-claude-convN`). SiloLink relays operator messages from this conv to your poll loop.\n- **Report conv** \u2014 the conv where the originating user is watching for your final findings. Passed to you as `report_to_conversation_id` in your launch prompt. Often different from the dispatch conv (e.g. dispatch is per-ticket, report is the user's group/DM channel).\n\nRouting rule, by message kind:\n\n| Message kind | Tool | Lands in | @-mention? |\n|---|---|---|---|\n| Routine progress (\"phase 2 done\", logs, heartbeats) | `remote_notify` | dispatch conv | no |\n| **Mid-task questions / blockers / requests for direction** | `mcp__silolink__remote_post({ conversation_id: <report_to_conversation_id> })` | **report conv** | **YES \u2014 start with `@<DispatcherHandle>`** |\n| Final findings on completion | `mcp__silolink__remote_post({ conversation_id: <report_to_conversation_id> })` | report conv | YES \u2014 start with `@<DispatcherHandle>` |\n\nWhy mid-task questions go to the **report conv** and not the dispatch conv: the dispatch conv (`silolink-claude-convN`) is per-session and the dispatcher's LlmAgent isn't typically subscribed to it. `remote_notify` with an @-mention there lands silently and the dispatcher never wakes. The report conv is the channel they read \u2014 @-mention dispatch fires there.\n\n### Self-kill when work is done\n\nDon't sit in the poll loop after your work for this dispatch is finished. When you reach a true terminal state, call `remote_notify(\"Done \u2014 unregistering\")` then `remote_unregister()` and exit cleanly. Orphan idle CCs each burn ~$0.50/hr on the safety-net timeout, so explicit self-kill is the primary exit path \u2014 when it's actually time.\n\n**HARD PRECONDITIONS \u2014 verify ALL before calling `remote_unregister()`. If any fails, do NOT self-kill; stay in the poll loop and finish.**\n\n1. **No uncommitted changes.** `git status --porcelain` returns empty in your cwd (and in any worktree you opened). If you made edits, commit them first.\n2. **No unpushed commits.** `git log @{u}..` returns empty on every branch you touched. Push before you exit.\n3. **MR opened if your runbook requires one.** Verify via `git_provider_tool get_mr` that the MR exists with `state: \"opened\"`. \"I plan to open it\" is not enough \u2014 open it, then verify.\n4. **No subprocess you started is still running.** Tests, builds, long scripts: wait for the exit code and capture the result. **\"Now running tests\" is NOT a terminal state** \u2014 \"tests passed (or failed with documented next steps)\" is.\n5. **Final findings posted, starting with `@<DispatcherHandle>`** per the @-mention discipline above. Final goes to `report_to_conversation_id` if set, otherwise the dispatch conv via `remote_notify`. Same rule applies to *any* mid-flight message that needed dispatcher action \u2014 they all required the mention, not just this last one.\n\nWhat counts as \"done\" is agent-specific (your runbook may add more preconditions on top of the five above). When in doubt: stay in the poll loop, `remote_notify` what you're waiting on, and let the operator decide.\n\nIf the work fails or you hit a question the operator must answer, do NOT self-kill. Stay in the poll loop and wait \u2014 exiting with unanswered questions strands the operator.\n\n### If a report-conv post fails\n\nIf `remote_post` to your `report_to_conversation_id` returns an error (404 not found, 403 forbidden, etc.):\n1. Retry once. Transient errors do happen.\n2. If the second attempt fails the same way, do NOT silently drop your findings. Post them to your **dispatch conv** instead with a clear note: `\u26A0 remote_post to report conv <X> returned <error> \u2014 posting findings here for triage`. Then exit.\n\nFindings landing somewhere a human can see them matters more than landing in the \"right\" channel. Never let an unreachable report conv strand a completed task.\n\n### Permissions\n\nYou were launched with `--dangerously-skip-permissions` and silolink has pre-cleared the bypass-permissions consent gate, so per-command permission prompts should not appear. If you DO hit one mid-task (usually CC's hardcoded circuit breaker on patterns like `rm -rf /` or `rm -rf ~`):\n\n- Do NOT sit waiting \u2014 there's no human at the keyboard to click \"Yes\".\n- Call `remote_ask` with the prompt text so the operator can decide.\n- The operator can also drive the prompt directly from the web UI's peek dialog (the `send_keys` control command sends keystrokes into your tmux pane).\n\nIf a prompt appeared on a routine command (not a circuit-breaker pattern), report it via `remote_notify` \u2014 it likely means the bypass-consent didn't get pre-written and silolink needs to be restarted.\n";
7
+ export declare const SILOLINK_CLAUDE_MD_BLOCK = "## Remote Communication (SiloLink)\n\nApplies only when launched via SiloLink (prompt contains `conversation_id` or tells you to `remote_register`). Ignore otherwise.\n\n- Register with `remote_register({ conversation_id })`.\n- Call `remote_load_context()` ONLY on a fresh session. Skip it when you were resumed \u2014 you already have the history.\n- All communication via `remote_notify` / `remote_ask`. Never print to the terminal; the user is in Harmoniq, not watching.\n- **@-mention discipline.** Your launch prompt has a `**Dispatched by:** @<DispatcherHandle>` line. ANY message that needs your dispatcher to act \u2014 mid-task questions, blockers, errors, requests for direction, AND your final completion \u2014 MUST start with that `@<DispatcherHandle>` **AND be posted to the report conv** (`mcp__silolink__remote_post({ conversation_id: <report_to_conversation_id> })`), not via `remote_notify` to the dispatch conv. Mention-dispatch only wakes the dispatcher's LlmAgent when the @-mention lands in a conversation they're a member of \u2014 the dispatch conv is per-session and they're typically not subscribed; the report conv is the channel they read. Routine progress notes (\"phase 2 done, starting tests\") still go via `remote_notify` (no @-mention needed).\n- Send a `remote_notify` after every major step (file edits, commits, merges, errors). Going silent = user blind.\n- Poll loop: `remote_poll()` blocks up to 30s server-side (longer blocks risk Claude killing the tool as hung \u2014 keep this value as-is). If `{pending:true}`, loop again. The poll loop is the **safety net**, not the primary exit path \u2014 see \"Self-kill when work is done\" below. After **~60 consecutive pending (~30 min idle)**, `remote_notify(\"Going idle\")`, `remote_unregister()`, exit.\n- Every polled message is new; the queue drops it on your next call. Never ignore one. `remote_load_context()` is history-only.\n- Prefer small/cheap models for mechanical work (running specs, PR creation, merges). Use one-shot sub-sessions when possible to keep the main polling session's context lean.\n\n### Two conversations are in play\n\nYou typically have TWO distinct conversation ids in your session:\n- **Dispatch conv** \u2014 the conv you `remote_register`'d on (e.g. `silolink-claude-convN`). SiloLink relays operator messages from this conv to your poll loop.\n- **Report conv** \u2014 the conv where the originating user is watching for your final findings. Passed to you as `report_to_conversation_id` in your launch prompt. Often different from the dispatch conv (e.g. dispatch is per-ticket, report is the user's group/DM channel).\n\nRouting rule, by message kind:\n\n| Message kind | Tool | Lands in | @-mention? |\n|---|---|---|---|\n| Routine progress (\"phase 2 done\", logs, heartbeats) | `remote_notify` | dispatch conv | no |\n| **Mid-task questions / blockers / requests for direction** | `mcp__silolink__remote_post({ conversation_id: <report_to_conversation_id> })` | **report conv** | **YES \u2014 start with `@<DispatcherHandle>`** |\n| Final findings on completion | `mcp__silolink__remote_post({ conversation_id: <report_to_conversation_id>, final: true })` | report conv | YES \u2014 start with `@<DispatcherHandle>` |\n\nPass `final: true` on that ONE completion post (`remote_post` or `remote_notify`). It tags the message as your completion for the dispatcher and tells the daemon it may end this session gracefully once no new work arrives for ~2 minutes. Never set it on progress updates.\n\nWhy mid-task questions go to the **report conv** and not the dispatch conv: the dispatch conv (`silolink-claude-convN`) is per-session and the dispatcher's LlmAgent isn't typically subscribed to it. `remote_notify` with an @-mention there lands silently and the dispatcher never wakes. The report conv is the channel they read \u2014 @-mention dispatch fires there.\n\n### Self-kill when work is done\n\nDon't sit in the poll loop after your work for this dispatch is finished. When you reach a true terminal state, call `remote_notify(\"Done \u2014 unregistering\")` then `remote_unregister()` and exit cleanly. Orphan idle CCs each burn ~$0.50/hr on the safety-net timeout, so explicit self-kill is the primary exit path \u2014 when it's actually time.\n\n**HARD PRECONDITIONS \u2014 verify ALL before calling `remote_unregister()`. If any fails, do NOT self-kill; stay in the poll loop and finish.**\n\n1. **No uncommitted changes.** `git status --porcelain` returns empty in your cwd (and in any worktree you opened). If you made edits, commit them first.\n2. **No unpushed commits.** `git log @{u}..` returns empty on every branch you touched. Push before you exit.\n3. **MR opened if your runbook requires one.** Verify via `git_provider_tool get_mr` that the MR exists with `state: \"opened\"`. \"I plan to open it\" is not enough \u2014 open it, then verify.\n4. **No subprocess you started is still running.** Tests, builds, long scripts: wait for the exit code and capture the result. **\"Now running tests\" is NOT a terminal state** \u2014 \"tests passed (or failed with documented next steps)\" is.\n5. **Final findings posted, starting with `@<DispatcherHandle>`** per the @-mention discipline above. Final goes to `report_to_conversation_id` if set, otherwise the dispatch conv via `remote_notify`. Same rule applies to *any* mid-flight message that needed dispatcher action \u2014 they all required the mention, not just this last one.\n\nWhat counts as \"done\" is agent-specific (your runbook may add more preconditions on top of the five above). When in doubt: stay in the poll loop, `remote_notify` what you're waiting on, and let the operator decide.\n\nIf the work fails or you hit a question the operator must answer, do NOT self-kill. Stay in the poll loop and wait \u2014 exiting with unanswered questions strands the operator.\n\n### If a report-conv post fails\n\nIf `remote_post` to your `report_to_conversation_id` returns an error (404 not found, 403 forbidden, etc.):\n1. Retry once. Transient errors do happen.\n2. If the second attempt fails the same way, do NOT silently drop your findings. Post them to your **dispatch conv** instead with a clear note: `\u26A0 remote_post to report conv <X> returned <error> \u2014 posting findings here for triage`. Then exit.\n\nFindings landing somewhere a human can see them matters more than landing in the \"right\" channel. Never let an unreachable report conv strand a completed task.\n\n### Permissions\n\nYou were launched with `--dangerously-skip-permissions` and silolink has pre-cleared the bypass-permissions consent gate, so per-command permission prompts should not appear. If you DO hit one mid-task (usually CC's hardcoded circuit breaker on patterns like `rm -rf /` or `rm -rf ~`):\n\n- Do NOT sit waiting \u2014 there's no human at the keyboard to click \"Yes\".\n- Call `remote_ask` with the prompt text so the operator can decide.\n- The operator can also drive the prompt directly from the web UI's peek dialog (the `send_keys` control command sends keystrokes into your tmux pane).\n\nIf a prompt appeared on a routine command (not a circuit-breaker pattern), report it via `remote_notify` \u2014 it likely means the bypass-consent didn't get pre-written and silolink needs to be restarted.\n";
8
8
  //# sourceMappingURL=claude-md-block.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"claude-md-block.d.ts","sourceRoot":"","sources":["../../src/cli/claude-md-block.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,eAAO,MAAM,wBAAwB,o3NA8DpC,CAAC"}
1
+ {"version":3,"file":"claude-md-block.d.ts","sourceRoot":"","sources":["../../src/cli/claude-md-block.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,eAAO,MAAM,wBAAwB,opOAgEpC,CAAC"}
@@ -29,7 +29,9 @@ Routing rule, by message kind:
29
29
  |---|---|---|---|
30
30
  | Routine progress ("phase 2 done", logs, heartbeats) | \`remote_notify\` | dispatch conv | no |
31
31
  | **Mid-task questions / blockers / requests for direction** | \`mcp__silolink__remote_post({ conversation_id: <report_to_conversation_id> })\` | **report conv** | **YES — start with \`@<DispatcherHandle>\`** |
32
- | Final findings on completion | \`mcp__silolink__remote_post({ conversation_id: <report_to_conversation_id> })\` | report conv | YES — start with \`@<DispatcherHandle>\` |
32
+ | Final findings on completion | \`mcp__silolink__remote_post({ conversation_id: <report_to_conversation_id>, final: true })\` | report conv | YES — start with \`@<DispatcherHandle>\` |
33
+
34
+ Pass \`final: true\` on that ONE completion post (\`remote_post\` or \`remote_notify\`). It tags the message as your completion for the dispatcher and tells the daemon it may end this session gracefully once no new work arrives for ~2 minutes. Never set it on progress updates.
33
35
 
34
36
  Why mid-task questions go to the **report conv** and not the dispatch conv: the dispatch conv (\`silolink-claude-convN\`) is per-session and the dispatcher's LlmAgent isn't typically subscribed to it. \`remote_notify\` with an @-mention there lands silently and the dispatcher never wakes. The report conv is the channel they read — @-mention dispatch fires there.
35
37
 
@@ -1 +1 @@
1
- {"version":3,"file":"claude-md-block.js","sourceRoot":"","sources":["../../src/cli/claude-md-block.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,CAAC,MAAM,wBAAwB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8DvC,CAAC"}
1
+ {"version":3,"file":"claude-md-block.js","sourceRoot":"","sources":["../../src/cli/claude-md-block.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,CAAC,MAAM,wBAAwB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAgEvC,CAAC"}
@@ -7,15 +7,70 @@
7
7
  * the file with O_TRUNC before the CLI prints anything. The daemon now owns its log —
8
8
  * opened with O_APPEND, under ~/.silolink/logs/ — so no wrapper is needed and a
9
9
  * double-start can never clobber history.
10
+ *
11
+ * 2026-08-24 (doug daemon, pop-os): a start script that redirected
12
+ * `>> ~/.silolink/logs/silolink-doug.log 2>&1` made fd 1/2 the SAME file the tee
13
+ * appends to, so 107 of 471 boot lines were doubled — and nothing in the file
14
+ * carried a timestamp except the boot banner. The tee now (1) fstat-compares fd 1/2
15
+ * against the log file and skips the tee for an fd that already IS the log,
16
+ * (2) stamps every line it writes to the file with an ISO-8601 timestamp, and
17
+ * (3) mutes console output for non-TTY fds (the file is the single sink) unless
18
+ * SILOLINK_CONSOLE=1 asks to keep it.
10
19
  */
11
20
  export declare function logFilePath(env?: string): string;
21
+ /** Per-fd line-boundary tracker so a timestamp is only ever inserted at a line start. */
22
+ export interface LineStampState {
23
+ atLineStart: boolean;
24
+ }
25
+ /**
26
+ * Prefix every line START in `text` with `<iso timestamp> `. Chunks may hold many
27
+ * newlines or none at all; the state carries the "are we at a line start" bit across
28
+ * chunks so a line split over several writes is stamped exactly once. Empty lines
29
+ * (a bare "\n" at a line start) are left blank rather than stamped.
30
+ */
31
+ export declare function stampLines(text: string, state: LineStampState, now?: () => string): string;
32
+ /** True when `fd` refers to the same inode as `filePath` (i.e. the fd IS the log file). */
33
+ export declare function fdIsFile(fd: number, filePath: string): boolean;
34
+ export interface FileLogStatus {
35
+ path: string;
36
+ bytes: number;
37
+ stdout_is_log: boolean;
38
+ stderr_is_log: boolean;
39
+ console_muted: boolean;
40
+ }
41
+ /** Snapshot of the attached file log for the health block (null when not attached). */
42
+ export declare function getFileLogStatus(): FileLogStatus | null;
43
+ export interface AttachOptions {
44
+ /** Override for tests: explicit log file path (default: logFilePath(env)). */
45
+ path?: string;
46
+ /** Override for tests: which fds back stdout/stderr (default 1 and 2). */
47
+ fds?: {
48
+ stdout: number;
49
+ stderr: number;
50
+ };
51
+ /** Override for tests: TTY-ness of each std stream (default: the real isTTY). */
52
+ isTTY?: {
53
+ stdout: boolean;
54
+ stderr: boolean;
55
+ };
56
+ /** Override for tests: SILOLINK_CONSOLE value (default: process.env). */
57
+ consoleEnv?: string | undefined;
58
+ /** Override for tests: timestamp source. */
59
+ now?: () => string;
60
+ }
12
61
  /**
13
62
  * Tee all stdout/stderr writes of THIS process into the env's append-mode log file,
14
- * prefixed by a timestamped session-start banner. Console behavior is unchanged —
15
- * tmux operators still see live output; the file is additive.
63
+ * prefixed by a timestamped session-start banner, with every subsequent line stamped.
64
+ *
65
+ * Per fd:
66
+ * - fd IS the log file (operator redirected `>> silolink-<env>.log`): write the stamped
67
+ * chunk through the ORIGINAL fd only — never also through the tee (no doubled lines).
68
+ * - fd is a TTY, or SILOLINK_CONSOLE=1: tee to the file (stamped) AND echo raw to the fd.
69
+ * - otherwise (fd is /dev/null, a pipe, some other file): file only — the file is the
70
+ * single sink and the redirect target stops accumulating a second copy.
16
71
  *
17
72
  * Returns the log path (for printing), or null when the log file could not be
18
73
  * opened — logging must never prevent the bridge from starting.
19
74
  */
20
- export declare function attachFileLog(env: string | undefined, version: string): string | null;
75
+ export declare function attachFileLog(env: string | undefined, version: string, opts?: AttachOptions): string | null;
21
76
  //# sourceMappingURL=file-log.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"file-log.d.ts","sourceRoot":"","sources":["../../src/cli/file-log.ts"],"names":[],"mappings":"AAIA;;;;;;;;;GASG;AACH,wBAAgB,WAAW,CAAC,GAAG,CAAC,EAAE,MAAM,GAAG,MAAM,CAGhD;AAED;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CA0BrF"}
1
+ {"version":3,"file":"file-log.d.ts","sourceRoot":"","sources":["../../src/cli/file-log.ts"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,WAAW,CAAC,GAAG,CAAC,EAAE,MAAM,GAAG,MAAM,CAGhD;AAED,yFAAyF;AACzF,MAAM,WAAW,cAAc;IAC7B,WAAW,EAAE,OAAO,CAAC;CACtB;AAED;;;;;GAKG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,cAAc,EAAE,GAAG,GAAE,MAAM,MAAuC,GAAG,MAAM,CAc1H;AAED,2FAA2F;AAC3F,wBAAgB,QAAQ,CAAC,EAAE,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAQ9D;AAED,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,aAAa,EAAE,OAAO,CAAC;IACvB,aAAa,EAAE,OAAO,CAAC;IACvB,aAAa,EAAE,OAAO,CAAC;CACxB;AAID,uFAAuF;AACvF,wBAAgB,gBAAgB,IAAI,aAAa,GAAG,IAAI,CAKvD;AAED,MAAM,WAAW,aAAa;IAC5B,8EAA8E;IAC9E,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,0EAA0E;IAC1E,GAAG,CAAC,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;IACzC,iFAAiF;IACjF,KAAK,CAAC,EAAE;QAAE,MAAM,EAAE,OAAO,CAAC;QAAC,MAAM,EAAE,OAAO,CAAA;KAAE,CAAC;IAC7C,yEAAyE;IACzE,UAAU,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAChC,4CAA4C;IAC5C,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CACpB;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,GAAE,aAAkB,GAAG,MAAM,GAAG,IAAI,CA8D/G"}
@@ -10,39 +10,142 @@ import { getConfigDir } from '../config/config-manager.js';
10
10
  * the file with O_TRUNC before the CLI prints anything. The daemon now owns its log —
11
11
  * opened with O_APPEND, under ~/.silolink/logs/ — so no wrapper is needed and a
12
12
  * double-start can never clobber history.
13
+ *
14
+ * 2026-08-24 (doug daemon, pop-os): a start script that redirected
15
+ * `>> ~/.silolink/logs/silolink-doug.log 2>&1` made fd 1/2 the SAME file the tee
16
+ * appends to, so 107 of 471 boot lines were doubled — and nothing in the file
17
+ * carried a timestamp except the boot banner. The tee now (1) fstat-compares fd 1/2
18
+ * against the log file and skips the tee for an fd that already IS the log,
19
+ * (2) stamps every line it writes to the file with an ISO-8601 timestamp, and
20
+ * (3) mutes console output for non-TTY fds (the file is the single sink) unless
21
+ * SILOLINK_CONSOLE=1 asks to keep it.
13
22
  */
14
23
  export function logFilePath(env) {
15
24
  const slug = env || 'default';
16
25
  return path.join(getConfigDir(), 'logs', `silolink-${slug}.log`);
17
26
  }
27
+ /**
28
+ * Prefix every line START in `text` with `<iso timestamp> `. Chunks may hold many
29
+ * newlines or none at all; the state carries the "are we at a line start" bit across
30
+ * chunks so a line split over several writes is stamped exactly once. Empty lines
31
+ * (a bare "\n" at a line start) are left blank rather than stamped.
32
+ */
33
+ export function stampLines(text, state, now = () => new Date().toISOString()) {
34
+ if (!text)
35
+ return text;
36
+ let out = '';
37
+ let i = 0;
38
+ while (i < text.length) {
39
+ const nl = text.indexOf('\n', i);
40
+ const end = nl === -1 ? text.length : nl + 1;
41
+ const seg = text.slice(i, end);
42
+ if (state.atLineStart && seg !== '\n')
43
+ out += `${now()} `;
44
+ out += seg;
45
+ state.atLineStart = seg.endsWith('\n');
46
+ i = end;
47
+ }
48
+ return out;
49
+ }
50
+ /** True when `fd` refers to the same inode as `filePath` (i.e. the fd IS the log file). */
51
+ export function fdIsFile(fd, filePath) {
52
+ try {
53
+ const a = fs.fstatSync(fd);
54
+ const b = fs.statSync(filePath);
55
+ return a.dev === b.dev && a.ino === b.ino;
56
+ }
57
+ catch {
58
+ return false;
59
+ }
60
+ }
61
+ let current = null;
62
+ /** Snapshot of the attached file log for the health block (null when not attached). */
63
+ export function getFileLogStatus() {
64
+ if (!current)
65
+ return null;
66
+ let bytes = 0;
67
+ try {
68
+ bytes = fs.statSync(current.path).size;
69
+ }
70
+ catch { /* unreadable → 0 */ }
71
+ return { ...current, bytes };
72
+ }
18
73
  /**
19
74
  * Tee all stdout/stderr writes of THIS process into the env's append-mode log file,
20
- * prefixed by a timestamped session-start banner. Console behavior is unchanged —
21
- * tmux operators still see live output; the file is additive.
75
+ * prefixed by a timestamped session-start banner, with every subsequent line stamped.
76
+ *
77
+ * Per fd:
78
+ * - fd IS the log file (operator redirected `>> silolink-<env>.log`): write the stamped
79
+ * chunk through the ORIGINAL fd only — never also through the tee (no doubled lines).
80
+ * - fd is a TTY, or SILOLINK_CONSOLE=1: tee to the file (stamped) AND echo raw to the fd.
81
+ * - otherwise (fd is /dev/null, a pipe, some other file): file only — the file is the
82
+ * single sink and the redirect target stops accumulating a second copy.
22
83
  *
23
84
  * Returns the log path (for printing), or null when the log file could not be
24
85
  * opened — logging must never prevent the bridge from starting.
25
86
  */
26
- export function attachFileLog(env, version) {
87
+ export function attachFileLog(env, version, opts = {}) {
27
88
  try {
28
- const logPath = logFilePath(env);
89
+ const logPath = opts.path ?? logFilePath(env);
29
90
  fs.mkdirSync(path.dirname(logPath), { recursive: true });
30
91
  const stream = fs.createWriteStream(logPath, { flags: 'a' });
31
92
  stream.write(`\n===== SiloLink session start ${new Date().toISOString()} ` +
32
93
  `(env=${env || 'default'} pid=${process.pid} v${version}) =====\n`);
33
- for (const std of [process.stdout, process.stderr]) {
94
+ // Make sure the file exists on disk before fstat comparison (createWriteStream opens lazily).
95
+ try {
96
+ fs.closeSync(fs.openSync(logPath, 'a'));
97
+ }
98
+ catch { /* best effort */ }
99
+ const fds = opts.fds ?? { stdout: 1, stderr: 2 };
100
+ const tty = opts.isTTY ?? { stdout: !!process.stdout.isTTY, stderr: !!process.stderr.isTTY };
101
+ const consoleEnv = 'consoleEnv' in opts ? opts.consoleEnv : process.env.SILOLINK_CONSOLE;
102
+ const keepConsole = consoleEnv === '1';
103
+ const now = opts.now;
104
+ const streams = [
105
+ ['stdout', process.stdout],
106
+ ['stderr', process.stderr],
107
+ ];
108
+ const isLog = { stdout: false, stderr: false };
109
+ let muted = false;
110
+ for (const [name, std] of streams) {
111
+ const fdIsLog = fdIsFile(fds[name], logPath);
112
+ isLog[name] = fdIsLog;
113
+ const echo = tty[name] || keepConsole;
114
+ if (!fdIsLog && !echo)
115
+ muted = true;
116
+ const state = { atLineStart: true };
34
117
  const orig = std.write.bind(std);
35
118
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
36
119
  std.write = (chunk, ...args) => {
120
+ const encoding = typeof args[0] === 'string' ? args[0] : undefined;
121
+ const text = typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString(encoding || 'utf8');
122
+ let stamped;
123
+ try {
124
+ stamped = stampLines(text, state, now);
125
+ }
126
+ catch {
127
+ stamped = text;
128
+ }
129
+ if (fdIsLog) {
130
+ // The fd already lands in the log: write the stamped text there once.
131
+ return orig(stamped, ...args.filter((a) => typeof a !== 'string'));
132
+ }
37
133
  try {
38
- stream.write(chunk);
134
+ stream.write(stamped);
39
135
  }
40
136
  catch {
41
137
  // Never let file-log failures break console output.
42
138
  }
43
- return orig(chunk, ...args);
139
+ if (echo)
140
+ return orig(chunk, ...args);
141
+ // Muted: honor a trailing callback so callers awaiting drain never hang.
142
+ const cb = args[args.length - 1];
143
+ if (typeof cb === 'function')
144
+ process.nextTick(cb);
145
+ return true;
44
146
  };
45
147
  }
148
+ current = { path: logPath, stdout_is_log: isLog.stdout, stderr_is_log: isLog.stderr, console_muted: muted };
46
149
  return logPath;
47
150
  }
48
151
  catch {
@@ -1 +1 @@
1
- {"version":3,"file":"file-log.js","sourceRoot":"","sources":["../../src/cli/file-log.ts"],"names":[],"mappings":"AAAA,OAAO,IAAI,MAAM,WAAW,CAAC;AAC7B,OAAO,EAAE,MAAM,SAAS,CAAC;AACzB,OAAO,EAAE,YAAY,EAAE,MAAM,6BAA6B,CAAC;AAE3D;;;;;;;;;GASG;AACH,MAAM,UAAU,WAAW,CAAC,GAAY;IACtC,MAAM,IAAI,GAAG,GAAG,IAAI,SAAS,CAAC;IAC9B,OAAO,IAAI,CAAC,IAAI,CAAC,YAAY,EAAE,EAAE,MAAM,EAAE,YAAY,IAAI,MAAM,CAAC,CAAC;AACnE,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,aAAa,CAAC,GAAuB,EAAE,OAAe;IACpE,IAAI,CAAC;QACH,MAAM,OAAO,GAAG,WAAW,CAAC,GAAG,CAAC,CAAC;QACjC,EAAE,CAAC,SAAS,CAAC,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;QACzD,MAAM,MAAM,GAAG,EAAE,CAAC,iBAAiB,CAAC,OAAO,EAAE,EAAE,KAAK,EAAE,GAAG,EAAE,CAAC,CAAC;QAC7D,MAAM,CAAC,KAAK,CACV,kCAAkC,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE,GAAG;YAC3D,QAAQ,GAAG,IAAI,SAAS,QAAQ,OAAO,CAAC,GAAG,KAAK,OAAO,WAAW,CACrE,CAAC;QAEF,KAAK,MAAM,GAAG,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC;YACnD,MAAM,IAAI,GAAG,GAAG,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;YACjC,8DAA8D;YAC7D,GAAW,CAAC,KAAK,GAAG,CAAC,KAAU,EAAE,GAAG,IAAW,EAAW,EAAE;gBAC3D,IAAI,CAAC;oBACH,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;gBACtB,CAAC;gBAAC,MAAM,CAAC;oBACP,oDAAoD;gBACtD,CAAC;gBACD,OAAQ,IAAqC,CAAC,KAAK,EAAE,GAAG,IAAI,CAAC,CAAC;YAChE,CAAC,CAAC;QACJ,CAAC;QACD,OAAO,OAAO,CAAC;IACjB,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC"}
1
+ {"version":3,"file":"file-log.js","sourceRoot":"","sources":["../../src/cli/file-log.ts"],"names":[],"mappings":"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"}
@@ -48,6 +48,14 @@ export interface AgentProcess {
48
48
  * provider-specific process lifecycle: spawning, health checks, resumption,
49
49
  * and cleanup.
50
50
  */
51
+ import type { LaunchBindingRegistry, LaunchLinkage } from './launch-binding.js';
52
+ export interface WedgeVerdict {
53
+ wedged: boolean;
54
+ /** Human-readable why (logged by callers): 'no-pane' | 'pane-gone' | 'pane-active' | 'frozen-Nmin' … */
55
+ reason: string;
56
+ /** The tmux pane name at decision time (null when the launcher tracks none). */
57
+ paneName: string | null;
58
+ }
51
59
  export interface AgentLauncher {
52
60
  /**
53
61
  * Launch a new agent process.
@@ -87,6 +95,8 @@ export interface AgentLauncher {
87
95
  * @returns true if any processes were nudged.
88
96
  */
89
97
  nudge(): boolean;
98
+ /** Nudge ONE conversation's pane (no backoff). @returns false when it has no pane. */
99
+ nudgeConversation?(conversationId: number, message?: string): boolean;
90
100
  /**
91
101
  * Whether this launcher is properly configured and ready to use.
92
102
  */
@@ -130,6 +140,14 @@ export interface AgentLauncher {
130
140
  sendTextAndSubmit(conversationId: number, text: string): boolean;
131
141
  /** Store the last resume/session ID for future resume */
132
142
  setLastResumeId(resumeId: string, conversationId?: number): void;
143
+ /** Share the launch→transport binding registry (see launch-binding.ts). */
144
+ setLaunchBindings?(registry: LaunchBindingRegistry): void;
145
+ /** One-shot: backend linkage (agent_session_id / launch_correlation_id) for the NEXT launch. */
146
+ setLaunchLinkage?(linkage: LaunchLinkage | null): void;
147
+ /** The resume id minted at launch for a conversation (Claude `--session-id`), if any. */
148
+ getMintedResumeId?(conversationId: number): string | undefined;
149
+ /** Pin the conversation's resume id on its registered Session now (minted id or discovery). */
150
+ syncResumeId?(conversationId: number): void;
133
151
  /** Launch a session triggered by an inbound message */
134
152
  launchOnMessage(conversationId?: number, triggerMessage?: string, llmAgentId?: number): Promise<boolean>;
135
153
  /** Internal launch — called by Bridge control channel handler */
@@ -144,6 +162,14 @@ export interface AgentLauncher {
144
162
  };
145
163
  /** Kill the session associated with a specific conversation */
146
164
  killByConversation(conversationId: number, reason?: string): void;
165
+ /**
166
+ * Is the conversation's pane wedged — present, NOT actively producing output,
167
+ * and frozen at least the launcher's FROZEN threshold? Built from the same
168
+ * primitives the periodic wedged-session reaper uses, so an inbound message
169
+ * can never recycle a pane the reaper would leave alone. Optional so
170
+ * non-tmux launchers may skip it (callers treat "absent" as not wedged).
171
+ */
172
+ isSessionWedged?(conversationId: number): WedgeVerdict;
147
173
  /** Kill all sessions */
148
174
  kill(): void;
149
175
  /** Send an interrupt to a session */
@@ -1 +1 @@
1
- {"version":3,"file":"agent-launcher.d.ts","sourceRoot":"","sources":["../../src/core/agent-launcher.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"agent-launcher.d.ts","sourceRoot":"","sources":["../../src/core/agent-launcher.ts"],"names":[],"mappings":"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"}
@@ -6,7 +6,16 @@ export interface AutoUpdaterDeps {
6
6
  env?: string;
7
7
  isIdle: () => boolean;
8
8
  gracefulStop: () => Promise<void>;
9
+ exec?: (file: string, args: string[], opts: {
10
+ timeout: number;
11
+ maxBuffer: number;
12
+ }) => Promise<{
13
+ stdout: string;
14
+ }>;
15
+ installLockPath?: string;
9
16
  }
17
+ /** Verify tarball bytes against an SRI string (sha512-…). Exported for tests. */
18
+ export declare function tarballMatchesIntegrity(data: Buffer, expectedIntegrity: string): boolean;
10
19
  /**
11
20
  * Self-update orchestrator. Periodically reconciles the installed version
12
21
  * against (a) the npm registry — "what's published" — and (b) the hub's policy
@@ -25,6 +34,8 @@ export declare class AutoUpdater {
25
34
  private readonly env?;
26
35
  private readonly isIdle;
27
36
  private readonly gracefulStop;
37
+ private readonly exec;
38
+ private readonly installLockPath;
28
39
  private timer;
29
40
  private initialTimer;
30
41
  private checking;
@@ -1 +1 @@
1
- {"version":3,"file":"auto-updater.d.ts","sourceRoot":"","sources":["../../src/core/auto-updater.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,aAAa,EAAiB,MAAM,0BAA0B,CAAC;AAC7E,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAMxD,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,cAAc,CAAC;IACvB,MAAM,EAAE,aAAa,CAAC;IAEtB,GAAG,CAAC,EAAE,MAAM,CAAC;IAGb,MAAM,EAAE,MAAM,OAAO,CAAC;IAGtB,YAAY,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CACnC;AAED;;;;;;;;;;;GAWG;AACH,qBAAa,WAAW;IACtB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAiB;IACxC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAgB;IACvC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAS;IAC9B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAgB;IACvC,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAsB;IAEnD,OAAO,CAAC,KAAK,CAA+C;IAC5D,OAAO,CAAC,YAAY,CAA8C;IAClE,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,QAAQ,CAAS;IAGzB,OAAO,CAAC,mBAAmB,CAAuB;IAGlD,OAAO,CAAC,aAAa,CAAuB;IAG5C,OAAO,CAAC,mBAAmB,CAAuB;gBAEtC,IAAI,EAAE,eAAe;IAQjC,OAAO,KAAK,OAAO,GAGlB;IAED,OAAO,KAAK,UAAU,GAErB;IAED,KAAK,IAAI,IAAI;IAmBb,IAAI,IAAI,IAAI;IAWZ,yEAAyE;IACnE,SAAS,IAAI,OAAO,CAAC,IAAI,CAAC;IAwChC;;;;OAIG;IACH,OAAO,CAAC,aAAa;IAiCrB;;;;;;;;;;;OAWG;IACH,OAAO,CAAC,OAAO;IAcf,OAAO,CAAC,MAAM;IA+Bd,OAAO,CAAC,MAAM;IAcd,4EAA4E;IAC5E,OAAO,CAAC,cAAc;IAItB;;;;;;OAMG;IACH,OAAO,CAAC,WAAW;IAiBnB;;;;;OAKG;YACW,sBAAsB;IAiBpC;;;;;OAKG;YACW,aAAa;IAqF3B;;;;;;;;;;OAUG;IACH,OAAO,CAAC,eAAe;CAQxB;AAiBD,wBAAgB,eAAe,IAAI,MAAM,CAExC;AAED,8FAA8F;AAC9F,wBAAgB,kBAAkB,CAAC,QAAQ,SAAoB,GAAG,OAAO,CAuBxE;AAED,wBAAgB,kBAAkB,CAAC,QAAQ,SAAoB,GAAG,IAAI,CAMrE;AAED,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,EAAE,MAAM,SAAiB,GAAG,IAAI,CAiD9F;AAcD;;;;;;GAMG;AACH,wBAAsB,qBAAqB,CACzC,WAAW,SAA+B,GACzC,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAexB;AAED;;;;;;GAMG;AACH,wBAAsB,kBAAkB,CAAC,IAAI,GAAE;IAAE,SAAS,CAAC,EAAE,OAAO,CAAA;CAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CA8B7F"}
1
+ {"version":3,"file":"auto-updater.d.ts","sourceRoot":"","sources":["../../src/core/auto-updater.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,aAAa,EAAiB,MAAM,0BAA0B,CAAC;AAC7E,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAOxD,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,cAAc,CAAC;IACvB,MAAM,EAAE,aAAa,CAAC;IAEtB,GAAG,CAAC,EAAE,MAAM,CAAC;IAGb,MAAM,EAAE,MAAM,OAAO,CAAC;IAGtB,YAAY,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAElC,IAAI,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,KAAK,OAAO,CAAC;QAAE,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACnH,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,iFAAiF;AACjF,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,MAAM,EAAE,iBAAiB,EAAE,MAAM,GAAG,OAAO,CAMxF;AAED;;;;;;;;;;;GAWG;AACH,qBAAa,WAAW;IACtB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAiB;IACxC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAgB;IACvC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAS;IAC9B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAgB;IACvC,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAsB;IACnD,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAuC;IAC5D,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAqB;IAErD,OAAO,CAAC,KAAK,CAA+C;IAC5D,OAAO,CAAC,YAAY,CAA8C;IAClE,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,QAAQ,CAAS;IAGzB,OAAO,CAAC,mBAAmB,CAAuB;IAGlD,OAAO,CAAC,aAAa,CAAuB;IAG5C,OAAO,CAAC,mBAAmB,CAAuB;gBAEtC,IAAI,EAAE,eAAe;IAUjC,OAAO,KAAK,OAAO,GAGlB;IAED,OAAO,KAAK,UAAU,GAErB;IAED,KAAK,IAAI,IAAI;IAmBb,IAAI,IAAI,IAAI;IAWZ,yEAAyE;IACnE,SAAS,IAAI,OAAO,CAAC,IAAI,CAAC;IAgDhC;;;;OAIG;IACH,OAAO,CAAC,aAAa;IAiCrB;;;;;;;;;;;OAWG;IACH,OAAO,CAAC,OAAO;IAcf,OAAO,CAAC,MAAM;IA+Bd,OAAO,CAAC,MAAM;IAcd,4EAA4E;IAC5E,OAAO,CAAC,cAAc;IAItB;;;;;;OAMG;IACH,OAAO,CAAC,WAAW;IAiBnB;;;;;OAKG;YACW,sBAAsB;IAiBpC;;;;;OAKG;YACW,aAAa;IA8H3B;;;;;;;;;;OAUG;IACH,OAAO,CAAC,eAAe;CAQxB;AAiBD,wBAAgB,eAAe,IAAI,MAAM,CAExC;AAED,8FAA8F;AAC9F,wBAAgB,kBAAkB,CAAC,QAAQ,SAAoB,GAAG,OAAO,CAuBxE;AAED,wBAAgB,kBAAkB,CAAC,QAAQ,SAAoB,GAAG,IAAI,CAMrE;AAED,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,EAAE,MAAM,SAAiB,GAAG,IAAI,CAiD9F;AAcD;;;;;;GAMG;AACH,wBAAsB,qBAAqB,CACzC,WAAW,SAA+B,GACzC,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAexB;AAED;;;;;;GAMG;AACH,wBAAsB,kBAAkB,CAAC,IAAI,GAAE;IAAE,SAAS,CAAC,EAAE,OAAO,CAAA;CAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CA8B7F"}
@@ -1,11 +1,22 @@
1
1
  import { execFile, execFileSync, spawn } from 'node:child_process';
2
- import { closeSync, mkdirSync, openSync, statSync, unlinkSync, writeSync } from 'node:fs';
3
- import { homedir } from 'node:os';
2
+ import { closeSync, mkdirSync, mkdtempSync, openSync, readFileSync, rmSync, statSync, unlinkSync, writeSync } from 'node:fs';
3
+ import { homedir, tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
+ import ssri from 'ssri';
5
6
  import { promisify } from 'node:util';
6
7
  import { PACKAGE_NAME, PACKAGE_VERSION } from './version.js';
7
8
  import * as semver from './semver-lite.js';
9
+ import { healthMonitor } from './health-monitor.js';
8
10
  const execFileAsync = promisify(execFile);
11
+ /** Verify tarball bytes against an SRI string (sha512-…). Exported for tests. */
12
+ export function tarballMatchesIntegrity(data, expectedIntegrity) {
13
+ try {
14
+ return !!ssri.checkData(data, expectedIntegrity);
15
+ }
16
+ catch {
17
+ return false;
18
+ }
19
+ }
9
20
  /**
10
21
  * Self-update orchestrator. Periodically reconciles the installed version
11
22
  * against (a) the npm registry — "what's published" — and (b) the hub's policy
@@ -24,6 +35,8 @@ export class AutoUpdater {
24
35
  env;
25
36
  isIdle;
26
37
  gracefulStop;
38
+ exec;
39
+ installLockPath;
27
40
  timer = null;
28
41
  initialTimer = null;
29
42
  checking = false;
@@ -43,6 +56,8 @@ export class AutoUpdater {
43
56
  this.env = deps.env;
44
57
  this.isIdle = deps.isIdle;
45
58
  this.gracefulStop = deps.gracefulStop;
59
+ this.exec = deps.exec ?? ((file, args, opts) => execFileAsync(file, args, opts));
60
+ this.installLockPath = deps.installLockPath;
46
61
  }
47
62
  get enabled() {
48
63
  // Default ON unless explicitly disabled.
@@ -88,11 +103,15 @@ export class AutoUpdater {
88
103
  this.checking = true;
89
104
  try {
90
105
  const current = PACKAGE_VERSION;
106
+ healthMonitor.setUpdater({ state: this.enabled ? 'checking' : 'disabled' });
91
107
  const policy = await this.client.getVersionPolicy();
92
108
  const npmLatest = await this.fetchNpmLatest();
109
+ healthMonitor.setUpdater({ latest: npmLatest });
93
110
  const target = this.resolveTarget(current, npmLatest, policy);
94
- if (!target)
111
+ if (!target) {
112
+ healthMonitor.setUpdater({ state: this.enabled ? 'current' : 'disabled', pinned: !!policy?.expected_integrity });
95
113
  return; // already current / nothing actionable
114
+ }
96
115
  const bump = semver.diffType(current, target.version);
97
116
  const decision = this.decide(current, target.version, bump, target.belowMin, policy);
98
117
  // Apply the hub's supply-chain pin only when it names the exact version
@@ -101,11 +120,13 @@ export class AutoUpdater {
101
120
  const expectedIntegrity = (policy?.target_version && target.version === policy.target_version)
102
121
  ? (policy.expected_integrity ?? null)
103
122
  : null;
123
+ healthMonitor.setUpdater({ pinned: expectedIntegrity !== null });
104
124
  if (decision === 'auto') {
105
125
  if (this.isIdle()) {
106
126
  await this.performUpdate(target.version, expectedIntegrity);
107
127
  }
108
128
  else {
129
+ healthMonitor.setUpdater({ state: 'deferred' });
109
130
  if (this.lastDeferredVersion !== target.version) {
110
131
  console.log(` Auto-update: v${target.version} ready — deferring until idle (a coding session is active)`);
111
132
  this.lastDeferredVersion = target.version;
@@ -113,6 +134,7 @@ export class AutoUpdater {
113
134
  }
114
135
  }
115
136
  else if (decision === 'notify') {
137
+ healthMonitor.setUpdater({ state: 'notify' });
116
138
  this.notify(current, target.version, bump, target.belowMin);
117
139
  }
118
140
  }
@@ -312,37 +334,83 @@ export class AutoUpdater {
312
334
  // One installer at a time per machine; losers defer to their next check,
313
335
  // by which time the winner has installed the version and the retry is a
314
336
  // fast no-op.
315
- if (!acquireInstallLock()) {
337
+ if (!acquireInstallLock(this.installLockPath)) {
316
338
  console.log(` Auto-update: another daemon on this machine is installing — deferring v${target} to the next check.`);
317
339
  return;
318
340
  }
319
341
  this.updating = true;
320
342
  this.stop(); // no more checks while we update/restart
343
+ healthMonitor.setUpdater({ state: 'installing' });
321
344
  const spec = `${PACKAGE_NAME}@${target}`;
322
- console.log(` Auto-update: installing ${spec} ...`);
345
+ const execOpts = { timeout: 300_000, maxBuffer: 16 * 1024 * 1024 };
346
+ let packDir = null;
347
+ const fail = (state, message, hint) => {
348
+ this.updating = false;
349
+ releaseInstallLock(this.installLockPath);
350
+ if (packDir) {
351
+ try {
352
+ rmSync(packDir, { recursive: true, force: true });
353
+ }
354
+ catch { /* best-effort */ }
355
+ }
356
+ healthMonitor.setUpdater({ state });
357
+ console.error(` Auto-update: ${message}`);
358
+ if (hint)
359
+ console.error(` Auto-update: ${hint}`);
360
+ // Restart the check timer so we retry later (e.g. once perms are fixed).
361
+ this.timer = setInterval(() => { void this.checkOnce(); }, this.intervalMs);
362
+ if (this.timer.unref)
363
+ this.timer.unref();
364
+ };
365
+ // Pinned install: verify the TARBALL ITSELF, not just the registry's
366
+ // metadata. `npm pack` fetches the exact tgz the install would use; its
367
+ // bytes are hashed with ssri and compared to the hub's expected_integrity
368
+ // before anything is installed. The registry pre-check above still runs
369
+ // first (cheap, and it catches a stale pin with a clear message).
370
+ let installSpec = spec;
371
+ if (expectedIntegrity) {
372
+ try {
373
+ packDir = mkdtempSync(join(tmpdir(), 'silolink-update-'));
374
+ const { stdout } = await this.exec('npm', ['pack', spec, '--pack-destination', packDir, '--ignore-scripts', '--no-fund', '--no-audit'], execOpts);
375
+ const file = stdout.trim().split('\n').filter(Boolean).pop() ?? '';
376
+ const tgz = join(packDir, file);
377
+ const data = readFileSync(tgz);
378
+ if (!tarballMatchesIntegrity(data, expectedIntegrity)) {
379
+ const actual = ssri.fromData(data, { algorithms: ['sha512'] }).toString();
380
+ fail('pinned-refused', `TARBALL INTEGRITY MISMATCH for v${target} — refusing install. expected ${expectedIntegrity}, downloaded ${actual}. (Possible npm tampering, or a stale pin in the hub policy.)`);
381
+ return;
382
+ }
383
+ console.log(` Auto-update: tarball integrity verified for v${target} (${file}).`);
384
+ installSpec = tgz;
385
+ }
386
+ catch (err) {
387
+ fail('failed', `could not fetch/verify the v${target} tarball — ${err.message}`);
388
+ return;
389
+ }
390
+ }
391
+ console.log(` Auto-update: installing ${installSpec} ...`);
323
392
  try {
324
393
  // --ignore-scripts: do NOT run the fetched package's lifecycle scripts
325
394
  // (pre/postinstall) — silo-link needs none at install time, and this
326
395
  // removes the install-script RCE vector if a published version is ever
327
396
  // malicious. Bin-linking still happens (it's core npm, not a script).
328
- const { stdout } = await execFileAsync('npm', ['install', '-g', spec, '--ignore-scripts', '--no-fund', '--no-audit'], { timeout: 300_000, maxBuffer: 16 * 1024 * 1024 });
397
+ const { stdout } = await this.exec('npm', ['install', '-g', installSpec, '--ignore-scripts', '--no-fund', '--no-audit'], execOpts);
329
398
  const tail = stdout.trim().split('\n').slice(-1)[0] ?? '';
330
399
  console.log(` Auto-update: install complete (${tail || 'ok'}). Restarting onto v${target}.`);
331
400
  }
332
401
  catch (err) {
333
402
  // Most common real-world failure: global prefix needs sudo. Don't crash —
334
403
  // log a clear, actionable banner and resume report-only behavior.
335
- this.updating = false;
336
- releaseInstallLock();
337
- console.error(` Auto-update: install FAILED — ${err.message}`);
338
- console.error(` Auto-update: leaving daemon on v${PACKAGE_VERSION}. Run "npm i -g ${spec}" manually (with sudo if your global npm prefix needs it), or set auto_update_enabled:false to silence this.`);
339
- // Restart the check timer so we retry later (e.g. once perms are fixed).
340
- this.timer = setInterval(() => { void this.checkOnce(); }, this.intervalMs);
341
- if (this.timer.unref)
342
- this.timer.unref();
404
+ fail('failed', `install FAILED — ${err.message}`, `leaving daemon on v${PACKAGE_VERSION}. Run "npm i -g ${spec}" manually (with sudo if your global npm prefix needs it), or set auto_update_enabled:false to silence this.`);
343
405
  return;
344
406
  }
345
- releaseInstallLock();
407
+ releaseInstallLock(this.installLockPath);
408
+ if (packDir) {
409
+ try {
410
+ rmSync(packDir, { recursive: true, force: true });
411
+ }
412
+ catch { /* best-effort */ }
413
+ }
346
414
  this.scheduleRestart();
347
415
  // Safety net: if the graceful drain wedges (a stuck usage POST, an
348
416
  // unresponsive transport close), the daemon would keep running the OLD
@@ -357,6 +425,7 @@ export class AutoUpdater {
357
425
  // Drain and exit. In managed mode the supervisor brings us back; otherwise
358
426
  // the detached waiter scheduled above does.
359
427
  await this.gracefulStop();
428
+ clearTimeout(hardExit);
360
429
  }
361
430
  /**
362
431
  * Arrange for the daemon to come back on the freshly-installed version.