@phnx-labs/agents-cli 1.22.114 → 1.22.115

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 (176) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/README.md +22 -102
  3. package/dist/browser.js +0 -0
  4. package/dist/cli/command-registry.js +0 -7
  5. package/dist/commands/accounts.d.ts +3 -77
  6. package/dist/commands/accounts.js +13 -366
  7. package/dist/commands/audit.js +1 -1
  8. package/dist/commands/auth.js +0 -2
  9. package/dist/commands/browser.d.ts +53 -0
  10. package/dist/commands/browser.js +199 -9
  11. package/dist/commands/doctor.d.ts +2 -3
  12. package/dist/commands/doctor.js +0 -11
  13. package/dist/commands/events.d.ts +1 -3
  14. package/dist/commands/events.js +4 -7
  15. package/dist/commands/exec.d.ts +0 -1
  16. package/dist/commands/exec.js +2 -7
  17. package/dist/commands/feed-watch.js +52 -8
  18. package/dist/commands/focus.js +1 -1
  19. package/dist/commands/go.d.ts +0 -17
  20. package/dist/commands/go.js +2 -19
  21. package/dist/commands/logs.js +1 -1
  22. package/dist/commands/mcp.js +8 -83
  23. package/dist/commands/memory.js +4 -47
  24. package/dist/commands/message.js +4 -4
  25. package/dist/commands/plugins.js +1 -93
  26. package/dist/commands/repo.js +0 -44
  27. package/dist/commands/resume.js +4 -1
  28. package/dist/commands/secrets-passthrough.js +2 -2
  29. package/dist/commands/send.d.ts +4 -4
  30. package/dist/commands/send.js +6 -51
  31. package/dist/commands/sessions-backup-setup.js +1 -1
  32. package/dist/commands/sessions-resume.js +0 -1
  33. package/dist/commands/sessions-share.d.ts +5 -7
  34. package/dist/commands/sessions-share.js +98 -49
  35. package/dist/commands/sessions.js +1 -8
  36. package/dist/commands/setup-browser.js +18 -2
  37. package/dist/commands/setup-computer.js +20 -6
  38. package/dist/commands/setup-secrets.js +24 -4
  39. package/dist/commands/setup-terminal.d.ts +3 -0
  40. package/dist/commands/setup-terminal.js +22 -0
  41. package/dist/commands/setup.d.ts +1 -1
  42. package/dist/commands/setup.js +20 -10
  43. package/dist/commands/skills.js +0 -8
  44. package/dist/commands/ssh.d.ts +6 -0
  45. package/dist/commands/ssh.js +92 -233
  46. package/dist/commands/sync.js +14 -5
  47. package/dist/commands/teams.js +1 -1
  48. package/dist/commands/traces.js +1 -1
  49. package/dist/lib/accounts/add.d.ts +0 -5
  50. package/dist/lib/accounts/add.js +1 -7
  51. package/dist/lib/artifacts-client.d.ts +20 -0
  52. package/dist/lib/artifacts-client.js +46 -0
  53. package/dist/lib/auth-mint.js +2 -2
  54. package/dist/lib/browser/runtime-state.d.ts +55 -0
  55. package/dist/lib/browser/runtime-state.js +99 -18
  56. package/dist/lib/browser/service.js +21 -1
  57. package/dist/lib/cli-resources.js +3 -1
  58. package/dist/lib/cloud/dispatch.js +1 -1
  59. package/dist/lib/cloudflare/creds.d.ts +10 -0
  60. package/dist/lib/cloudflare/creds.js +46 -0
  61. package/dist/lib/cloudflare/provision.d.ts +35 -0
  62. package/dist/lib/cloudflare/provision.js +144 -0
  63. package/dist/lib/computer/sessions-list.d.ts +55 -0
  64. package/dist/lib/computer/sessions-list.js +168 -1
  65. package/dist/lib/daemon/daemon.js +8 -1
  66. package/dist/lib/daemon/feed-stream-service.d.ts +23 -0
  67. package/dist/lib/daemon/feed-stream-service.js +40 -0
  68. package/dist/lib/daemon-services.d.ts +1 -1
  69. package/dist/lib/daemon-services.js +5 -0
  70. package/dist/lib/devices/connect.d.ts +49 -5
  71. package/dist/lib/devices/connect.js +169 -21
  72. package/dist/lib/feed/envelope.d.ts +79 -0
  73. package/dist/lib/feed/envelope.js +23 -0
  74. package/dist/lib/feed/events.d.ts +6 -0
  75. package/dist/lib/feed/events.js +8 -0
  76. package/dist/lib/feed/hub-server.d.ts +86 -0
  77. package/dist/lib/feed/hub-server.js +334 -0
  78. package/dist/lib/feed/hub.d.ts +95 -0
  79. package/dist/lib/feed/hub.js +255 -0
  80. package/dist/lib/feed/tool-activity.d.ts +108 -0
  81. package/dist/lib/feed/tool-activity.js +313 -0
  82. package/dist/lib/feed/tools.d.ts +198 -0
  83. package/dist/lib/feed/tools.js +265 -0
  84. package/dist/lib/feed/watch.d.ts +50 -50
  85. package/dist/lib/feed/watch.js +147 -16
  86. package/dist/lib/format.d.ts +1 -1
  87. package/dist/lib/format.js +1 -1
  88. package/dist/lib/git.d.ts +0 -16
  89. package/dist/lib/git.js +0 -58
  90. package/dist/lib/helper-versions.js +1 -1
  91. package/dist/lib/hosts/remote-cmd.d.ts +51 -1
  92. package/dist/lib/hosts/remote-cmd.js +125 -8
  93. package/dist/lib/hosts/remote-cmd.test-fixture.d.ts +2 -0
  94. package/dist/lib/hosts/remote-cmd.test-fixture.js +22 -0
  95. package/dist/lib/mcp.js +17 -11
  96. package/dist/lib/probe.d.ts +4 -1
  97. package/dist/lib/probe.js +5 -2
  98. package/dist/lib/pwsh.d.ts +33 -0
  99. package/dist/lib/pwsh.js +56 -0
  100. package/dist/lib/redact.d.ts +8 -0
  101. package/dist/lib/redact.js +11 -0
  102. package/dist/lib/refresh.d.ts +6 -2
  103. package/dist/lib/refresh.js +92 -72
  104. package/dist/lib/secrets-cli.d.ts +11 -0
  105. package/dist/lib/secrets-cli.js +30 -0
  106. package/dist/lib/secrets-client.js +3 -2
  107. package/dist/lib/session/detached.d.ts +7 -0
  108. package/dist/lib/session/detached.js +29 -0
  109. package/dist/lib/session/remote/peer-stream.d.ts +24 -2
  110. package/dist/lib/session/remote/peer-stream.js +33 -6
  111. package/dist/lib/session/sync/backend.d.ts +3 -3
  112. package/dist/lib/session/sync/backend.js +3 -3
  113. package/dist/lib/session/sync/provision.d.ts +1 -1
  114. package/dist/lib/session/sync/provision.js +2 -2
  115. package/dist/lib/sessions-client.js +0 -3
  116. package/dist/lib/setup-tool-install.d.ts +3 -0
  117. package/dist/lib/setup-tool-install.js +26 -0
  118. package/dist/lib/setup-tool-status.d.ts +22 -0
  119. package/dist/lib/setup-tool-status.js +215 -0
  120. package/dist/lib/share-runtime.d.ts +11 -0
  121. package/dist/lib/share-runtime.js +63 -0
  122. package/dist/lib/smart-launch.d.ts +1 -5
  123. package/dist/lib/smart-launch.js +3 -11
  124. package/dist/lib/ssh-exec.d.ts +44 -0
  125. package/dist/lib/ssh-exec.js +119 -0
  126. package/dist/lib/startup/command-registry.d.ts +6 -4
  127. package/dist/lib/startup/command-registry.js +10 -7
  128. package/dist/lib/state.js +2 -2
  129. package/dist/lib/storage/selection.d.ts +2 -2
  130. package/dist/lib/storage/selection.js +2 -2
  131. package/dist/lib/sync-umbrella.d.ts +5 -0
  132. package/dist/lib/sync-umbrella.js +18 -10
  133. package/dist/lib/traces/backend.d.ts +1 -2
  134. package/dist/lib/traces/backend.js +1 -2
  135. package/dist/lib/traces/provision.d.ts +1 -1
  136. package/dist/lib/traces/provision.js +2 -2
  137. package/dist/lib/types.d.ts +8 -6
  138. package/package.json +2 -3
  139. package/dist/commands/artifacts-setup.d.ts +0 -53
  140. package/dist/commands/artifacts-setup.js +0 -161
  141. package/dist/commands/artifacts.d.ts +0 -18
  142. package/dist/commands/artifacts.js +0 -58
  143. package/dist/commands/attach.d.ts +0 -12
  144. package/dist/commands/attach.js +0 -86
  145. package/dist/commands/auth-mint.d.ts +0 -12
  146. package/dist/commands/auth-mint.js +0 -108
  147. package/dist/commands/reconnect.d.ts +0 -46
  148. package/dist/commands/reconnect.js +0 -115
  149. package/dist/commands/share.d.ts +0 -293
  150. package/dist/commands/share.js +0 -1424
  151. package/dist/lib/share/analytics.d.ts +0 -13
  152. package/dist/lib/share/analytics.js +0 -45
  153. package/dist/lib/share/backend.d.ts +0 -120
  154. package/dist/lib/share/backend.js +0 -176
  155. package/dist/lib/share/capture.d.ts +0 -31
  156. package/dist/lib/share/capture.js +0 -174
  157. package/dist/lib/share/config.d.ts +0 -72
  158. package/dist/lib/share/config.js +0 -211
  159. package/dist/lib/share/delete.d.ts +0 -123
  160. package/dist/lib/share/delete.js +0 -173
  161. package/dist/lib/share/html.d.ts +0 -20
  162. package/dist/lib/share/html.js +0 -88
  163. package/dist/lib/share/http-error.d.ts +0 -53
  164. package/dist/lib/share/http-error.js +0 -65
  165. package/dist/lib/share/og.d.ts +0 -26
  166. package/dist/lib/share/og.js +0 -84
  167. package/dist/lib/share/provision.d.ts +0 -127
  168. package/dist/lib/share/provision.js +0 -285
  169. package/dist/lib/share/publish.d.ts +0 -379
  170. package/dist/lib/share/publish.js +0 -818
  171. package/dist/lib/share/worker-template.d.ts +0 -27
  172. package/dist/lib/share/worker-template.js +0 -2424
  173. package/dist/lib/storage/index.d.ts +0 -14
  174. package/dist/lib/storage/index.js +0 -14
  175. package/dist/lib/storage/visibility.d.ts +0 -82
  176. package/dist/lib/storage/visibility.js +0 -99
@@ -0,0 +1,79 @@
1
+ import type { SessionWatchRow, SessionWatchScopeStatus } from '../session/watch.js';
2
+ import type { ToolSetupRow } from '../setup-tool-status.js';
3
+ import type { AttentionItem } from './attention.js';
4
+ import type { ActivityEvent } from './activity.js';
5
+ import type { ToolRow } from './tools.js';
6
+ type Base = {
7
+ v: 1;
8
+ type: string;
9
+ streamId: string;
10
+ sequence: number;
11
+ scope: string;
12
+ };
13
+ export type FeedWatchEnvelope = Base & {
14
+ type: 'reset';
15
+ capturedAt: number;
16
+ agents: SessionWatchRow[];
17
+ attention: AttentionItem[];
18
+ tools: ToolRow[];
19
+ /** This device's tool-setup rows; see `setup.snapshot` for why it is a set. */
20
+ setup: ToolSetupRow[];
21
+ } | Base & {
22
+ type: 'agent.upsert';
23
+ rowKey: string;
24
+ agent: SessionWatchRow;
25
+ } | Base & {
26
+ type: 'agent.remove';
27
+ rowKey: string;
28
+ } | Base & {
29
+ type: 'attention.upsert';
30
+ rowKey: string;
31
+ attention: AttentionItem;
32
+ } | Base & {
33
+ type: 'attention.remove';
34
+ rowKey: string;
35
+ } | Base & {
36
+ type: 'activity.append';
37
+ event: ActivityEvent;
38
+ }
39
+ /** A browser task or computer run, projected by `feed/tools.ts`. */
40
+ | Base & {
41
+ type: 'tool.upsert';
42
+ rowKey: string;
43
+ tool: ToolRow;
44
+ } | Base & {
45
+ type: 'tool.remove';
46
+ rowKey: string;
47
+ }
48
+ /**
49
+ * The whole tool-setup set for one device, replaced at once.
50
+ *
51
+ * Deliberately a SNAPSHOT and not per-row upserts: `getCachedToolSetup` always
52
+ * answers for every tool in `SETUP_TOOLS`, so "browser is ready" and "computer
53
+ * needs setup" are one coherent reading of the box taken at one moment. Sending
54
+ * three independent upserts would let a consumer render a mix of two different
55
+ * readings, and there is no row to *remove* — a tool that is not installed is a
56
+ * row saying so, which is exactly what a Setup pane must show.
57
+ */
58
+ | Base & {
59
+ type: 'setup.snapshot';
60
+ capturedAt: number;
61
+ setup: ToolSetupRow[];
62
+ } | Base & {
63
+ type: 'scope';
64
+ capturedAt: number;
65
+ status: SessionWatchScopeStatus;
66
+ reason?: string;
67
+ } | Base & {
68
+ type: 'heartbeat';
69
+ capturedAt: number;
70
+ };
71
+ export type FeedWatchPayload = FeedWatchEnvelope extends infer Envelope ? Envelope extends FeedWatchEnvelope ? Omit<Envelope, 'v' | 'streamId' | 'sequence'> : never : never;
72
+ /** Per-subscriber stream identity and monotonic sequence. */
73
+ export declare class FeedWatchState {
74
+ readonly streamId: string;
75
+ private sequence;
76
+ constructor(streamId?: `${string}-${string}-${string}-${string}-${string}`);
77
+ emit(event: FeedWatchPayload): FeedWatchEnvelope;
78
+ }
79
+ export {};
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The feed stream's envelope contract and its sequencer.
3
+ *
4
+ * Extracted from `watch.ts` so the three modules that all speak this protocol —
5
+ * the producer (`watch.ts`), the shared collector (`hub.ts`), and the socket
6
+ * boundary (`hub-server.ts`) — can depend on the contract without depending on
7
+ * each other. `watch.ts` needs `FeedHub` to share its local collector and
8
+ * `hub.ts` needs the sequencer, so leaving both here is what keeps that from
9
+ * being an import cycle.
10
+ *
11
+ * `watch.ts` re-exports everything in this file, so every existing importer is
12
+ * unaffected.
13
+ */
14
+ import { randomUUID } from 'node:crypto';
15
+ /** Per-subscriber stream identity and monotonic sequence. */
16
+ export class FeedWatchState {
17
+ streamId;
18
+ sequence = 0;
19
+ constructor(streamId = randomUUID()) { this.streamId = streamId; }
20
+ emit(event) {
21
+ return { v: 1, streamId: this.streamId, sequence: ++this.sequence, ...event };
22
+ }
23
+ }
@@ -12,6 +12,12 @@
12
12
  * - Performance tracking: withTiming() wrapper for any async function
13
13
  */
14
14
  import type { ActorKind } from '../actor.js';
15
+ /**
16
+ * The directory today's ledger is appended to. Exported for the tool-activity
17
+ * collector, which watches it instead of re-running `agents computer sessions`
18
+ * on a timer to notice a new `computer.action` (see `feed/tool-activity.ts`).
19
+ */
20
+ export declare function getEventsDir(): string;
15
21
  export type EventLevel = 'audit' | 'warn' | 'info' | 'debug';
16
22
  export type EventType = 'agent.run.start' | 'agent.run.end' | 'agent.spawn.start' | 'agent.spawn.end' | 'run.dispatched' | 'run.launch' | 'daemon.start' | 'daemon.stop' | 'daemon.error' | 'daemon.info' | 'routine.start' | 'routine.end' | 'watchdog.action' | 'version.install' | 'version.switch' | 'version.remove' | 'skill.install' | 'skill.remove' | 'browser.launch' | 'browser.close' | 'browser.navigate' | 'browser.screenshot' | 'computer.action' | 'secrets.get' | 'secrets.unlocked' | 'secrets.create' | 'secrets.import' | 'secrets.export' | 'secrets.view' | 'secrets.lease-denied' | 'secrets.lease-expire' | 'secrets.set' | 'secrets.delete' | 'secrets.rename' | 'cloud.dispatch' | 'cloud.complete' | 'cloud.cancel' | 'cloud.message' | 'teams.create' | 'teams.add' | 'teams.start' | 'teams.complete' | 'teams.disband' | 'hook.fire' | 'hook.complete' | 'hook.error' | 'mcp.add' | 'mcp.remove' | 'mcp.register' | 'resource.sync' | 'rotation.resolved' | 'rotation.unresolved' | 'command.start' | 'command.end' | 'perf.timing' | 'session.start' | 'session.end' | 'webhook.received' | 'webhook.authorized' | 'webhook.rejected' | 'webhook.matched' | 'webhook.fired' | 'webhook.failed' | 'webhook.handler.start' | 'webhook.handler.end' | 'plan.created' | 'pr.opened' | 'pr.merged' | 'worktree.created' | 'worktree.removed' | 'commit.created' | 'pushed' | 'subagent.spawned' | 'artifact.created' | 'task.completed' | 'checklist.created' | 'status.posted' | 'file.edited' | 'factory.command' | 'factory.action' | 'factory.uri' | 'factory.launch' | 'friction' | 'error' | 'warn' | 'info' | 'debug';
17
23
  /** Every known event kind. Derived from {@link EVENT_TYPE_TABLE}, never hand-listed. */
@@ -88,6 +88,14 @@ function eventsPath(date = new Date()) {
88
88
  function eventsDir(date = new Date()) {
89
89
  return path.dirname(eventsPath(date));
90
90
  }
91
+ /**
92
+ * The directory today's ledger is appended to. Exported for the tool-activity
93
+ * collector, which watches it instead of re-running `agents computer sessions`
94
+ * on a timer to notice a new `computer.action` (see `feed/tool-activity.ts`).
95
+ */
96
+ export function getEventsDir() {
97
+ return eventsDir();
98
+ }
91
99
  /** Default retention period in days. */
92
100
  const DEFAULT_RETENTION_DAYS = 7;
93
101
  /** Default total footprint for the active log plus gzip archives (50 MiB). */
@@ -0,0 +1,86 @@
1
+ import { FeedHub } from './hub.js';
2
+ import type { FeedWatchEnvelope } from './envelope.js';
3
+ /**
4
+ * Outbound bytes a single reader may leave unflushed before it is dropped.
5
+ *
6
+ * `socket.write()` never blocks: when a reader stops draining — a stopped
7
+ * process, a suspended laptop, a debugger paused on a breakpoint — node buffers
8
+ * the backlog in the DAEMON's heap, without limit. A busy fleet stream is a few
9
+ * KB per second, so a reader wedged for an hour is tens of megabytes the daemon
10
+ * can never reclaim, and the daemon is the process every other surface depends
11
+ * on. A reader that cannot keep up is dropped loudly instead: it can reconnect
12
+ * and be caught up from held state, which is cheaper than the backlog.
13
+ */
14
+ export declare const HUB_CLIENT_BACKLOG_LIMIT: number;
15
+ /**
16
+ * How long a reader gets to send its scope line before it is REJECTED.
17
+ *
18
+ * The handshake is required, not defaulted. Silently treating a missing or
19
+ * unparseable scope as `fleet` meant a reader that sent nothing — or sent
20
+ * garbage, or sent its line after the grace elapsed — was quietly subscribed to
21
+ * the whole-fleet collector: it started ssh children to every peer on behalf of a
22
+ * client that never asked for them, and delivered peer data to a client that may
23
+ * have wanted only this box. A boundary that guesses is worse than one that
24
+ * refuses, so an unusable handshake is reported and the connection ends.
25
+ */
26
+ export declare const HUB_HANDSHAKE_GRACE_MS = 2000;
27
+ /** The canonical socket path (POSIX) / pipe-name key (Windows). */
28
+ export declare function feedHubSocketPath(): string;
29
+ /** The address a server listens on and a client connects to. */
30
+ export declare function feedHubEndpoint(socketPath?: string): string;
31
+ /**
32
+ * Serve one {@link FeedHub} to other processes. Each accepted connection is one
33
+ * subscriber; closing it detaches, and the last detach stops the fan-out.
34
+ */
35
+ export declare class FeedHubServer {
36
+ private readonly hub;
37
+ private readonly socketPathOverride?;
38
+ private readonly localHub?;
39
+ private server;
40
+ private readonly detachers;
41
+ /** Which collector each reader is attached to, so a failure reaches only its own. */
42
+ private readonly attachedTo;
43
+ /** Readers dropped for exceeding {@link HUB_CLIENT_BACKLOG_LIMIT}. Observability. */
44
+ droppedForBacklog: number;
45
+ /** Readers refused for a missing, invalid, or late scope line. Observability. */
46
+ rejectedHandshakes: number;
47
+ /**
48
+ * @param hub the FLEET collector (every reachable peer plus this box).
49
+ * @param localHub the LOCAL-only collector, served to a reader that asks for
50
+ * `scope: 'local'`. Optional: a server without one answers
51
+ * every reader from the fleet hub, which is what the fleet
52
+ * stream already contained.
53
+ */
54
+ constructor(hub: FeedHub, socketPathOverride?: string | undefined, localHub?: FeedHub | undefined);
55
+ /** Report a collector failure to its readers and end those connections. */
56
+ private failReaders;
57
+ /** Subscribers currently connected. Observability + tests. */
58
+ get clientCount(): number;
59
+ start(): Promise<void>;
60
+ stop(): Promise<void>;
61
+ }
62
+ /**
63
+ * Wait until the hub accepts a connection, or the deadline passes.
64
+ *
65
+ * `ensureDaemonStarted()` returns as soon as the daemon PROCESS is spawned, which
66
+ * is well before that process has loaded its services and bound this socket.
67
+ * Retrying immediately therefore raced the bind and failed on a daemon that was
68
+ * about to be perfectly healthy — reported to the operator as "the shared feed
69
+ * stream is unavailable". Resolves true once a connect succeeds.
70
+ */
71
+ export declare function waitForHub(endpoint?: string, deadlineMs?: number, intervalMs?: number): Promise<boolean>;
72
+ /**
73
+ * Read the shared stream from the hub until `signal` aborts.
74
+ *
75
+ * Rejects when the hub is not reachable. That is deliberate: a client that
76
+ * quietly ran its own `watchFleetFeed` instead would restore the per-caller
77
+ * ssh fan-out, so the caller starts the daemon and retries rather than
78
+ * degrading into the thing this replaced.
79
+ */
80
+ export declare function streamFeedFromHub(options: {
81
+ signal: AbortSignal;
82
+ emit: (event: FeedWatchEnvelope) => void;
83
+ endpoint?: string;
84
+ /** Which collector to attach to. Defaults to the whole fleet. */
85
+ scope?: 'fleet' | 'local';
86
+ }): Promise<void>;
@@ -0,0 +1,334 @@
1
+ /**
2
+ * The shared feed collector, exposed to other processes.
3
+ *
4
+ * {@link FeedHub} already collapses N readers in ONE process to one fleet
5
+ * fan-out. The readers that matter are in DIFFERENT processes — the extension's
6
+ * leader child, the menu-bar helper, an operator's `agents feed watch --json` —
7
+ * so the hub has to be reachable across the process boundary or each of them
8
+ * opens its own ssh-per-peer fan-out anyway.
9
+ *
10
+ * This is that boundary, and it is deliberately the thinnest possible one: a
11
+ * UNIX socket (named pipe on Windows) that writes the SAME NDJSON envelopes
12
+ * `agents feed watch --json` has always written, one per line. A client is a
13
+ * `readline` over the socket; there is no request/response protocol, no framing
14
+ * of its own, and no second schema to keep in sync.
15
+ *
16
+ * The daemon owns the server (`FeedStreamService`), which is what makes it one
17
+ * scheduler and one executor: the hub dials peers, the clients only render. A
18
+ * client MUST NOT fall back to running its own fan-out when the socket is
19
+ * absent — that is the double-connection bug this module exists to remove — so
20
+ * {@link streamFeedFromHub} fails loud and the caller starts the daemon.
21
+ */
22
+ import * as fs from 'node:fs';
23
+ import * as net from 'node:net';
24
+ import * as path from 'node:path';
25
+ import { createInterface } from 'node:readline';
26
+ import { getHelpersDir } from '../state.js';
27
+ import { ipcEndpoint } from '../platform/ipc.js';
28
+ const IS_WINDOWS = process.platform === 'win32';
29
+ const SOCKET_NAME = 'feed-stream.sock';
30
+ /**
31
+ * Outbound bytes a single reader may leave unflushed before it is dropped.
32
+ *
33
+ * `socket.write()` never blocks: when a reader stops draining — a stopped
34
+ * process, a suspended laptop, a debugger paused on a breakpoint — node buffers
35
+ * the backlog in the DAEMON's heap, without limit. A busy fleet stream is a few
36
+ * KB per second, so a reader wedged for an hour is tens of megabytes the daemon
37
+ * can never reclaim, and the daemon is the process every other surface depends
38
+ * on. A reader that cannot keep up is dropped loudly instead: it can reconnect
39
+ * and be caught up from held state, which is cheaper than the backlog.
40
+ */
41
+ export const HUB_CLIENT_BACKLOG_LIMIT = 4 * 1024 * 1024;
42
+ /**
43
+ * How long a reader gets to send its scope line before it is REJECTED.
44
+ *
45
+ * The handshake is required, not defaulted. Silently treating a missing or
46
+ * unparseable scope as `fleet` meant a reader that sent nothing — or sent
47
+ * garbage, or sent its line after the grace elapsed — was quietly subscribed to
48
+ * the whole-fleet collector: it started ssh children to every peer on behalf of a
49
+ * client that never asked for them, and delivered peer data to a client that may
50
+ * have wanted only this box. A boundary that guesses is worse than one that
51
+ * refuses, so an unusable handshake is reported and the connection ends.
52
+ */
53
+ export const HUB_HANDSHAKE_GRACE_MS = 2_000;
54
+ /** Bytes of handshake accepted before the reader is rejected outright. */
55
+ const HUB_HANDSHAKE_MAX_BYTES = 1024;
56
+ /** The scopes a reader may ask for. */
57
+ const HUB_SCOPES = new Set(['fleet', 'local']);
58
+ /** The canonical socket path (POSIX) / pipe-name key (Windows). */
59
+ export function feedHubSocketPath() {
60
+ return path.join(getHelpersDir(), 'feed', SOCKET_NAME);
61
+ }
62
+ /** The address a server listens on and a client connects to. */
63
+ export function feedHubEndpoint(socketPath = feedHubSocketPath()) {
64
+ return ipcEndpoint(socketPath);
65
+ }
66
+ /**
67
+ * Serve one {@link FeedHub} to other processes. Each accepted connection is one
68
+ * subscriber; closing it detaches, and the last detach stops the fan-out.
69
+ */
70
+ export class FeedHubServer {
71
+ hub;
72
+ socketPathOverride;
73
+ localHub;
74
+ server = null;
75
+ detachers = new Map();
76
+ /** Which collector each reader is attached to, so a failure reaches only its own. */
77
+ attachedTo = new Map();
78
+ /** Readers dropped for exceeding {@link HUB_CLIENT_BACKLOG_LIMIT}. Observability. */
79
+ droppedForBacklog = 0;
80
+ /** Readers refused for a missing, invalid, or late scope line. Observability. */
81
+ rejectedHandshakes = 0;
82
+ /**
83
+ * @param hub the FLEET collector (every reachable peer plus this box).
84
+ * @param localHub the LOCAL-only collector, served to a reader that asks for
85
+ * `scope: 'local'`. Optional: a server without one answers
86
+ * every reader from the fleet hub, which is what the fleet
87
+ * stream already contained.
88
+ */
89
+ constructor(hub, socketPathOverride, localHub) {
90
+ this.hub = hub;
91
+ this.socketPathOverride = socketPathOverride;
92
+ this.localHub = localHub;
93
+ // A collector that cannot start is reported to every reader attached to it
94
+ // and the connection is ended, so a consumer sees a failure instead of an
95
+ // indefinitely silent stream it cannot distinguish from an idle fleet.
96
+ for (const collector of [hub, localHub]) {
97
+ if (collector)
98
+ collector.onFailure = (error) => this.failReaders(collector, error);
99
+ }
100
+ }
101
+ /** Report a collector failure to its readers and end those connections. */
102
+ failReaders(collector, error) {
103
+ for (const [socket, attached] of this.attachedTo) {
104
+ if (attached !== collector || socket.destroyed)
105
+ continue;
106
+ socket.write(`${JSON.stringify({ v: 1, type: 'error', scope: '', error: error.message })}\n`);
107
+ socket.end();
108
+ }
109
+ }
110
+ /** Subscribers currently connected. Observability + tests. */
111
+ get clientCount() { return this.detachers.size; }
112
+ async start() {
113
+ const socketPath = this.socketPathOverride ?? feedHubSocketPath();
114
+ const endpoint = this.socketPathOverride ?? feedHubEndpoint();
115
+ const dir = path.dirname(socketPath);
116
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
117
+ if (!IS_WINDOWS) {
118
+ fs.chmodSync(dir, 0o700);
119
+ // A crashed daemon leaves the socket file behind; it accepts nothing, so
120
+ // removing it is the only way to bind. Named pipes vanish with their owner.
121
+ try {
122
+ fs.unlinkSync(socketPath);
123
+ }
124
+ catch { /* nothing stale to remove */ }
125
+ }
126
+ this.server = net.createServer((socket) => {
127
+ const end = () => {
128
+ this.detachers.get(socket)?.();
129
+ this.detachers.delete(socket);
130
+ this.attachedTo.delete(socket);
131
+ };
132
+ socket.on('close', end);
133
+ // A reader that dies mid-write surfaces as an error, not a close, and
134
+ // leaving it subscribed would hold every peer connection open forever.
135
+ socket.on('error', end);
136
+ /** Refuse this reader, telling it why rather than hanging up silently. */
137
+ const reject = (reason) => {
138
+ clearTimeout(grace);
139
+ if (socket.destroyed)
140
+ return;
141
+ this.rejectedHandshakes += 1;
142
+ socket.write(`${JSON.stringify({ v: 1, type: 'error', scope: '', error: `feed handshake rejected: ${reason}` })}\n`);
143
+ socket.end();
144
+ };
145
+ const attach = (hub) => {
146
+ if (socket.destroyed || this.detachers.has(socket))
147
+ return;
148
+ const detach = hub.subscribe((event) => {
149
+ if (socket.destroyed)
150
+ return;
151
+ socket.write(`${JSON.stringify(event)}\n`);
152
+ // Checked AFTER the write, so the reader is judged on a real backlog
153
+ // rather than on one envelope's size.
154
+ if (socket.writableLength > HUB_CLIENT_BACKLOG_LIMIT) {
155
+ this.droppedForBacklog += 1;
156
+ // `destroy` rather than `end`: a reader this far behind is not going
157
+ // to drain a graceful FIN either, and the point is to release the
158
+ // buffered bytes now. 'close' fires and detaches it.
159
+ socket.destroy(new Error(`feed reader dropped: ${socket.writableLength} bytes unflushed exceeds the ${HUB_CLIENT_BACKLOG_LIMIT}-byte budget`));
160
+ }
161
+ });
162
+ this.detachers.set(socket, detach);
163
+ this.attachedTo.set(socket, hub);
164
+ // A collector that ALREADY failed must not leave this reader waiting for
165
+ // a stream that is never coming.
166
+ if (hub.lastFailure)
167
+ this.failReaders(hub, hub.lastFailure);
168
+ };
169
+ // The scope line is REQUIRED and must arrive within the grace window.
170
+ let handshake = '';
171
+ const grace = setTimeout(() => reject(`no scope line within ${HUB_HANDSHAKE_GRACE_MS}ms`), HUB_HANDSHAKE_GRACE_MS);
172
+ grace.unref();
173
+ socket.on('data', (chunk) => {
174
+ if (socket.destroyed)
175
+ return;
176
+ // A line arriving AFTER this reader is attached is a protocol error: the
177
+ // scope is settled and a second one cannot retroactively change it.
178
+ if (this.detachers.has(socket)) {
179
+ reject('scope sent after the stream was already open');
180
+ return;
181
+ }
182
+ handshake += chunk.toString('utf-8');
183
+ const newline = handshake.indexOf('\n');
184
+ if (newline < 0) {
185
+ if (handshake.length > HUB_HANDSHAKE_MAX_BYTES)
186
+ reject('scope line exceeded the handshake budget');
187
+ return;
188
+ }
189
+ clearTimeout(grace);
190
+ let scope;
191
+ try {
192
+ scope = JSON.parse(handshake.slice(0, newline)).scope;
193
+ }
194
+ catch {
195
+ reject('scope line is not valid JSON');
196
+ return;
197
+ }
198
+ if (typeof scope !== 'string' || !HUB_SCOPES.has(scope)) {
199
+ reject(`unknown scope ${JSON.stringify(scope)}; expected "fleet" or "local"`);
200
+ return;
201
+ }
202
+ if (scope === 'local' && !this.localHub) {
203
+ // Serving the fleet collector instead would start peer connections a
204
+ // local-only reader never asked for.
205
+ reject('this server has no local collector');
206
+ return;
207
+ }
208
+ attach(scope === 'local' ? this.localHub : this.hub);
209
+ });
210
+ socket.once('end', () => clearTimeout(grace));
211
+ });
212
+ await new Promise((resolve, reject) => {
213
+ const listener = this.server;
214
+ listener.once('error', reject);
215
+ if (IS_WINDOWS) {
216
+ listener.listen(endpoint, () => resolve());
217
+ return;
218
+ }
219
+ // Restored on EVERY exit path. A listen error (the path is taken, the dir
220
+ // vanished) used to leave the process umask at 0o077 for good, so every
221
+ // later file this process created — a cache write, a journal — silently
222
+ // became owner-only. `once` guards the double-restore when both the
223
+ // success and error paths fire.
224
+ const previousUmask = process.umask(0o077);
225
+ let restored = false;
226
+ const restoreUmask = () => { if (!restored) {
227
+ restored = true;
228
+ process.umask(previousUmask);
229
+ } };
230
+ listener.once('error', restoreUmask);
231
+ listener.listen(socketPath, () => {
232
+ try {
233
+ fs.chmodSync(socketPath, 0o600);
234
+ resolve();
235
+ }
236
+ catch (error) {
237
+ reject(error);
238
+ }
239
+ finally {
240
+ restoreUmask();
241
+ }
242
+ });
243
+ });
244
+ }
245
+ async stop() {
246
+ for (const [socket, detach] of this.detachers) {
247
+ detach();
248
+ socket.destroy();
249
+ }
250
+ this.detachers.clear();
251
+ this.attachedTo.clear();
252
+ const server = this.server;
253
+ this.server = null;
254
+ if (server)
255
+ await new Promise((resolve) => server.close(() => resolve()));
256
+ await this.hub.close();
257
+ await this.localHub?.close();
258
+ }
259
+ }
260
+ /**
261
+ * Wait until the hub accepts a connection, or the deadline passes.
262
+ *
263
+ * `ensureDaemonStarted()` returns as soon as the daemon PROCESS is spawned, which
264
+ * is well before that process has loaded its services and bound this socket.
265
+ * Retrying immediately therefore raced the bind and failed on a daemon that was
266
+ * about to be perfectly healthy — reported to the operator as "the shared feed
267
+ * stream is unavailable". Resolves true once a connect succeeds.
268
+ */
269
+ export async function waitForHub(endpoint = feedHubEndpoint(), deadlineMs = 10_000, intervalMs = 100) {
270
+ const deadline = Date.now() + deadlineMs;
271
+ for (;;) {
272
+ const reachable = await new Promise((resolve) => {
273
+ const probe = net.createConnection(endpoint);
274
+ const settle = (ok) => { probe.destroy(); resolve(ok); };
275
+ probe.once('connect', () => settle(true));
276
+ probe.once('error', () => settle(false));
277
+ });
278
+ if (reachable)
279
+ return true;
280
+ if (Date.now() >= deadline)
281
+ return false;
282
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
283
+ }
284
+ }
285
+ /**
286
+ * Read the shared stream from the hub until `signal` aborts.
287
+ *
288
+ * Rejects when the hub is not reachable. That is deliberate: a client that
289
+ * quietly ran its own `watchFleetFeed` instead would restore the per-caller
290
+ * ssh fan-out, so the caller starts the daemon and retries rather than
291
+ * degrading into the thing this replaced.
292
+ */
293
+ export function streamFeedFromHub(options) {
294
+ return new Promise((resolve, reject) => {
295
+ const socket = net.createConnection(options.endpoint ?? feedHubEndpoint());
296
+ let connected = false;
297
+ // Registered before anything else touches the socket: a connect failure on
298
+ // a missing socket path is emitted as an 'error' with no listener attached
299
+ // yet, which node raises as an uncaught exception rather than rejecting.
300
+ socket.on('error', (error) => finish(error));
301
+ const stop = () => { socket.destroy(); };
302
+ options.signal.addEventListener('abort', stop, { once: true });
303
+ const finish = (error) => {
304
+ options.signal.removeEventListener('abort', stop);
305
+ reader.close();
306
+ if (error && !connected)
307
+ reject(error);
308
+ else
309
+ resolve();
310
+ };
311
+ const reader = createInterface({ input: socket });
312
+ // `readline.Interface` re-emits its input stream's error on ITSELF, and an
313
+ // Interface with no 'error' listener raises it as an uncaught exception —
314
+ // so handling it on the socket alone is not enough. Routed to the same
315
+ // `finish` so a mid-stream input failure ends the read once.
316
+ reader.on('error', (error) => finish(error));
317
+ reader.on('line', (line) => {
318
+ if (!line)
319
+ return;
320
+ try {
321
+ const event = JSON.parse(line);
322
+ // Protocol only: an unversioned line is not a feed envelope.
323
+ if (event.v === 1)
324
+ options.emit(event);
325
+ }
326
+ catch { /* a partial/foreign line is not state */ }
327
+ });
328
+ socket.on('connect', () => {
329
+ connected = true;
330
+ socket.write(`${JSON.stringify({ v: 1, scope: options.scope ?? 'fleet' })}\n`);
331
+ });
332
+ socket.on('close', () => finish());
333
+ });
334
+ }
@@ -0,0 +1,95 @@
1
+ import { FeedWatchState, type FeedWatchEnvelope } from './envelope.js';
2
+ /** Activity events replayed to a late subscriber. The lane is a rolling view,
3
+ * so a bounded tail is the honest amount of history to hand over. */
4
+ export declare const HUB_ACTIVITY_REPLAY = 50;
5
+ /**
6
+ * The per-scope row state the hub has observed. This is a projection of the
7
+ * events already delivered, never an independent gather: nothing here reads a
8
+ * file, runs a command, or dials a peer.
9
+ */
10
+ export declare class FeedHubState {
11
+ private readonly scopes;
12
+ private readonly activity;
13
+ private scope;
14
+ apply(event: FeedWatchEnvelope): void;
15
+ /** The envelopes that bring a fresh subscriber to the current state. */
16
+ snapshot(state: FeedWatchState): FeedWatchEnvelope[];
17
+ /** Scopes the hub has seen. Observability + tests. */
18
+ get scopeNames(): string[];
19
+ }
20
+ /** The collector a hub owns: it runs until the signal aborts, emitting envelopes. */
21
+ export type HubFanOut = (options: {
22
+ signal: AbortSignal;
23
+ emit: (event: FeedWatchEnvelope) => void;
24
+ reconnectMs?: number;
25
+ }) => Promise<void>;
26
+ /** Told to every attached reader when the shared fan-out cannot start. */
27
+ export type HubFailureListener = (error: Error) => void;
28
+ interface FeedHubOptions {
29
+ /**
30
+ * The collector to own — the fleet ssh fan-out, or the local watcher. Required
31
+ * rather than defaulted so this module depends on neither, which is what keeps
32
+ * `watch.ts` free to depend on THIS module for its shared local collector.
33
+ */
34
+ watch: HubFanOut;
35
+ /** Forwarded to the fan-out. */
36
+ reconnectMs?: number;
37
+ }
38
+ /**
39
+ * The shared collector. Construct one per process; call {@link subscribe} per
40
+ * reader.
41
+ */
42
+ export declare class FeedHub {
43
+ private readonly options;
44
+ private readonly subscribers;
45
+ private readonly held;
46
+ private controller;
47
+ private running;
48
+ /**
49
+ * Bumped on every start/stop. A fan-out's completion handler only clears state
50
+ * when its own generation is still current, so a run winding down cannot clear
51
+ * a newer one's controller.
52
+ */
53
+ private generation;
54
+ /** The most recent fan-out failure, if the current generation hit one. */
55
+ lastFailure: Error | null;
56
+ /**
57
+ * Called when the fan-out rejects. Without this the failure lived only in a
58
+ * dropped promise, so readers sat attached to a collector that had already died
59
+ * and saw an idle stream instead of an error. Settable so the socket server can
60
+ * attach after construction.
61
+ */
62
+ onFailure: HubFailureListener | null;
63
+ private readonly watch;
64
+ constructor(options: FeedHubOptions);
65
+ /** Readers currently attached. The fan-out runs iff this is > 0. */
66
+ get readerCount(): number;
67
+ /** Is the single shared fan-out running right now? */
68
+ get active(): boolean;
69
+ /** The held per-scope state, for observability and tests. */
70
+ get state(): FeedHubState;
71
+ /**
72
+ * Attach a reader. It is immediately served a snapshot of the held state, then
73
+ * every later event. Returns the detach function; the fan-out stops when the
74
+ * last reader detaches.
75
+ */
76
+ subscribe(emit: (event: FeedWatchEnvelope) => void): () => void;
77
+ /** Stop the fan-out and detach every reader. */
78
+ close(): Promise<void>;
79
+ /** Await the in-flight fan-out's teardown. Tests assert no overlap with it. */
80
+ settled(): Promise<void>;
81
+ /**
82
+ * Start the single fan-out, waiting for any previous one to finish first.
83
+ *
84
+ * The wait is the whole point. `stop()` aborts and returns immediately, but the
85
+ * fan-out it aborted is still tearing down ssh children. A reader that detaches
86
+ * and immediately reattaches — a VS Code window reloading, a menu-bar popover
87
+ * closing and reopening — therefore used to start a SECOND fan-out alongside
88
+ * the dying one: two ssh children per peer, two collectors, for as long as the
89
+ * overlap lasted. Serializing on the previous run makes that impossible.
90
+ */
91
+ private start;
92
+ private stop;
93
+ private broadcast;
94
+ }
95
+ export {};