@phnx-labs/agents-cli 1.22.46 → 1.22.47
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +279 -0
- package/LICENSE +102 -182
- package/README.md +31 -18
- package/dist/cli/command-registry.d.ts +1 -1
- package/dist/cli/command-registry.js +4 -2
- package/dist/commands/accounts.js +56 -12
- package/dist/commands/auth.js +7 -5
- package/dist/commands/browser-sessions-picker.js +9 -7
- package/dist/commands/browser.js +266 -15
- package/dist/commands/config.js +26 -4
- package/dist/commands/cost.d.ts +1 -1
- package/dist/commands/detach.d.ts +13 -0
- package/dist/commands/detach.js +11 -8
- package/dist/commands/exec.js +55 -38
- package/dist/commands/feed-watch.d.ts +2 -0
- package/dist/commands/feed-watch.js +28 -0
- package/dist/commands/feed.js +27 -0
- package/dist/commands/feedback.js +3 -19
- package/dist/commands/insights.d.ts +1 -1
- package/dist/commands/insights.js +2 -2
- package/dist/commands/lease.js +3 -3
- package/dist/commands/routines.js +1 -1
- package/dist/commands/sessions-picker-factory.d.ts +2 -1
- package/dist/commands/sessions-picker-factory.js +1 -1
- package/dist/commands/sessions-stop.d.ts +24 -0
- package/dist/commands/sessions-stop.js +66 -0
- package/dist/commands/sessions-trace.d.ts +82 -0
- package/dist/commands/sessions-trace.js +387 -0
- package/dist/commands/sessions.js +4 -0
- package/dist/commands/ssh.d.ts +15 -0
- package/dist/commands/ssh.js +194 -16
- package/dist/commands/teams.js +34 -2
- package/dist/commands/view.d.ts +2 -0
- package/dist/commands/view.js +18 -31
- package/dist/commands/watchdog.js +9 -14
- package/dist/lib/account-capabilities.d.ts +4 -0
- package/dist/lib/account-capabilities.js +13 -9
- package/dist/lib/account-registry.d.ts +2 -0
- package/dist/lib/account-registry.js +30 -2
- package/dist/lib/accounting/rotate.d.ts +9 -0
- package/dist/lib/accounting/rotate.js +12 -2
- package/dist/lib/accounting/usage.d.ts +70 -3
- package/dist/lib/accounting/usage.js +235 -63
- package/dist/lib/agent-modes.js +29 -1
- package/dist/lib/agent-spec/agents.d.ts +4 -3
- package/dist/lib/agent-spec/agents.js +14 -6
- package/dist/lib/analytics/mix-commands.d.ts +1 -1
- package/dist/lib/analytics/mix-commands.js +2 -2
- package/dist/lib/auth-health.d.ts +13 -3
- package/dist/lib/auth-health.js +50 -8
- package/dist/lib/browser/devices.d.ts +19 -0
- package/dist/lib/browser/devices.js +23 -0
- package/dist/lib/browser/ipc.d.ts +1 -1
- package/dist/lib/browser/ipc.js +33 -7
- package/dist/lib/browser/profiles.d.ts +106 -1
- package/dist/lib/browser/profiles.js +300 -17
- package/dist/lib/browser/remote-control.d.ts +26 -2
- package/dist/lib/browser/remote-control.js +33 -3
- package/dist/lib/browser/runtime-state.d.ts +9 -0
- package/dist/lib/browser/runtime-state.js +24 -8
- package/dist/lib/browser/service.d.ts +37 -0
- package/dist/lib/browser/service.js +78 -20
- package/dist/lib/browser/sessions-list.d.ts +1 -3
- package/dist/lib/browser/sessions-list.js +3 -16
- package/dist/lib/browser/types.d.ts +12 -1
- package/dist/lib/codex-policy.d.ts +11 -1
- package/dist/lib/codex-policy.js +20 -5
- package/dist/lib/config-keys.d.ts +4 -3
- package/dist/lib/config-keys.js +17 -7
- package/dist/lib/daemon/runner.js +15 -2
- package/dist/lib/daemon-ticks.js +2 -2
- package/dist/lib/device-config.d.ts +25 -8
- package/dist/lib/device-config.js +90 -17
- package/dist/lib/devices/config-migration.d.ts +7 -0
- package/dist/lib/devices/config-migration.js +67 -3
- package/dist/lib/devices/health.d.ts +27 -2
- package/dist/lib/devices/health.js +39 -9
- package/dist/lib/devices/interactive-host.d.ts +15 -0
- package/dist/lib/devices/interactive-host.js +53 -0
- package/dist/lib/devices/registry.d.ts +61 -4
- package/dist/lib/devices/registry.js +94 -38
- package/dist/lib/devices/stats-cache.d.ts +6 -0
- package/dist/lib/devices/stats-cache.js +10 -2
- package/dist/lib/exec.d.ts +5 -3
- package/dist/lib/exec.js +32 -16
- package/dist/lib/feed/answer.d.ts +29 -0
- package/dist/lib/feed/answer.js +145 -0
- package/dist/lib/feed/attention.d.ts +83 -0
- package/dist/lib/feed/attention.js +0 -0
- package/dist/lib/feed/feed.d.ts +109 -0
- package/dist/lib/feed/feed.js +154 -3
- package/dist/lib/feed/pr-status.d.ts +14 -0
- package/dist/lib/feed/pr-status.js +47 -0
- package/dist/lib/feed/watch.d.ts +59 -0
- package/dist/lib/feed/watch.js +180 -0
- package/dist/lib/fleet/capture.js +6 -0
- package/dist/lib/fleet/remote-login.js +8 -3
- package/dist/lib/fleet/types.d.ts +17 -0
- package/dist/lib/harness/adapter.d.ts +11 -0
- package/dist/lib/harness/adapters/claude.js +38 -24
- package/dist/lib/harness/adapters/codex.js +2 -4
- package/dist/lib/hooks/cache.js +8 -0
- package/dist/lib/hooks/match.d.ts +3 -0
- package/dist/lib/hooks/match.js +25 -0
- package/dist/lib/hosts/passthrough.js +16 -0
- package/dist/lib/hosts/registry.js +7 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/install-menubar.d.ts +13 -0
- package/dist/lib/menubar/install-menubar.js +26 -2
- package/dist/lib/open-url.d.ts +60 -2
- package/dist/lib/open-url.js +167 -12
- package/dist/lib/refresh-coordinator.d.ts +1 -1
- package/dist/lib/refresh-coordinator.js +1 -1
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
- package/dist/lib/session/active.d.ts +67 -0
- package/dist/lib/session/active.js +69 -1
- package/dist/lib/session/db.js +4 -2
- package/dist/lib/session/discover.d.ts +2 -0
- package/dist/lib/session/discover.js +12 -6
- package/dist/lib/session/prompt.d.ts +35 -0
- package/dist/lib/session/prompt.js +56 -0
- package/dist/lib/session/remote/watch.d.ts +1 -1
- package/dist/lib/session/remote/watch.js +6 -1
- package/dist/lib/session/render.js +7 -3
- package/dist/lib/session/session-cache.js +5 -1
- package/dist/lib/session/trajectory-compare.d.ts +47 -0
- package/dist/lib/session/trajectory-compare.js +116 -0
- package/dist/lib/session/trajectory-html.d.ts +15 -0
- package/dist/lib/session/trajectory-html.js +750 -0
- package/dist/lib/session/trajectory-lineage.d.ts +92 -0
- package/dist/lib/session/trajectory-lineage.js +195 -0
- package/dist/lib/session/trajectory-text.d.ts +37 -0
- package/dist/lib/session/trajectory-text.js +277 -0
- package/dist/lib/session/trajectory.d.ts +102 -0
- package/dist/lib/session/trajectory.js +335 -0
- package/dist/lib/startup/command-registry.d.ts +3 -1
- package/dist/lib/startup/command-registry.js +5 -2
- package/dist/lib/state.d.ts +1 -1
- package/dist/lib/state.js +4 -2
- package/dist/lib/teams/agents.js +9 -0
- package/dist/lib/terminal/inject.d.ts +19 -0
- package/dist/lib/terminal/inject.js +19 -9
- package/dist/lib/tmux/session.d.ts +46 -13
- package/dist/lib/tmux/session.js +173 -27
- package/dist/lib/types.d.ts +16 -0
- package/dist/lib/usage-backoff.d.ts +2 -1
- package/dist/lib/usage-backoff.js +60 -19
- package/dist/lib/usage-refresh.d.ts +17 -3
- package/dist/lib/usage-refresh.js +57 -11
- package/dist/lib/watchdog/log.d.ts +1 -1
- package/dist/lib/watchdog/log.js +1 -1
- package/dist/lib/watchdog/runner.d.ts +36 -45
- package/dist/lib/watchdog/runner.js +150 -119
- package/dist/lib/watchdog/service.d.ts +0 -1
- package/dist/lib/watchdog/service.js +0 -1
- package/dist/lib/watchdog/watchdog-agent.d.ts +38 -0
- package/dist/lib/watchdog/watchdog-agent.js +59 -0
- package/dist/lib/watchdog/watchdog.d.ts +11 -2
- package/dist/lib/watchdog/watchdog.js +37 -104
- package/package.json +2 -2
- package/dist/commands/usage.d.ts +0 -27
- package/dist/commands/usage.js +0 -96
package/dist/lib/feed/feed.d.ts
CHANGED
|
@@ -30,12 +30,54 @@ export interface AnswerRecord {
|
|
|
30
30
|
/** Whether the operator identity was verified against the registry. */
|
|
31
31
|
verified?: boolean;
|
|
32
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* Where an attention record came from, strongest evidence first.
|
|
35
|
+
* hook — a harness event (AskUserQuestion / permission / notification).
|
|
36
|
+
* declared — the agent said it is stuck (`agents feed post --blocked`).
|
|
37
|
+
* lifecycle — a structural session signal (plan handoff / permission wait).
|
|
38
|
+
* heuristic — an inferred prose question that decays.
|
|
39
|
+
* system — a synthetic card the feed computed (runaway / needy / PR review).
|
|
40
|
+
*/
|
|
41
|
+
export type AttentionSource = 'hook' | 'declared' | 'lifecycle' | 'heuristic' | 'system';
|
|
42
|
+
/**
|
|
43
|
+
* The lifecycle state of an attention record. A block is `open` until it is
|
|
44
|
+
* answered from some surface, `answered` once a surface claimed it, `consumed`
|
|
45
|
+
* once the agent read the reply, `continued` once the agent moved on, and
|
|
46
|
+
* `resolved` for a terminal clear (the block file is being removed). This is the
|
|
47
|
+
* axis the operator projection ranks on — only `open` needs a human.
|
|
48
|
+
*/
|
|
49
|
+
export type AttentionState = 'open' | 'answered' | 'consumed' | 'continued' | 'resolved';
|
|
50
|
+
/**
|
|
51
|
+
* A cursor into the source that produced a block — the transcript's last write
|
|
52
|
+
* (`lastActivityMs`) or a specific transcript `eventId`. It is what lets a
|
|
53
|
+
* resolution tombstone say "this generation was resolved at THIS point" so a
|
|
54
|
+
* stale lifecycle re-read cannot resurrect it while the session sits still, yet
|
|
55
|
+
* a genuinely newer turn (a strictly later cursor) is allowed through as a new
|
|
56
|
+
* generation. See {@link AttentionResolution} and `reconcileAttention`.
|
|
57
|
+
*/
|
|
58
|
+
export interface SourceCursor {
|
|
59
|
+
lastActivityMs?: number;
|
|
60
|
+
eventId?: string;
|
|
61
|
+
}
|
|
33
62
|
export interface OpenBlock {
|
|
34
63
|
blockId: string;
|
|
35
64
|
sessionId: string;
|
|
36
65
|
mailboxId: string;
|
|
37
66
|
host: string;
|
|
38
67
|
runtime: string;
|
|
68
|
+
/**
|
|
69
|
+
* Generation key for this block's CURRENT question. A new AskUserQuestion (or a
|
|
70
|
+
* new `--blocked` post) in the same session mints a new generation, so a
|
|
71
|
+
* resolution tombstone for the previous generation cannot suppress the fresh
|
|
72
|
+
* ask. Derived from `ts` when a writer did not stamp it — see {@link blockGeneration}.
|
|
73
|
+
*/
|
|
74
|
+
generation?: string;
|
|
75
|
+
/** How this block came to exist. Derived from {@link kind} when absent — see {@link blockSource}. */
|
|
76
|
+
source?: AttentionSource;
|
|
77
|
+
/** Lifecycle state. Derived from the answer/continue markers when absent — see {@link deriveBlockState}. */
|
|
78
|
+
state?: AttentionState;
|
|
79
|
+
/** Where in the source this block's generation sits — carried onto its resolution tombstone. */
|
|
80
|
+
sourceCursor?: SourceCursor;
|
|
39
81
|
/** Indexed launch origin, added at read time when the live session is known. */
|
|
40
82
|
origin?: 'cli' | 'routine';
|
|
41
83
|
/** Routine definition name when origin is `routine`. */
|
|
@@ -121,6 +163,66 @@ export interface FeedAskStats {
|
|
|
121
163
|
totalAskCount: number;
|
|
122
164
|
recentAskTimestamps: string[];
|
|
123
165
|
}
|
|
166
|
+
/**
|
|
167
|
+
* Why an attention generation stopped needing a human — a resolution tombstone.
|
|
168
|
+
* answered — a surface recorded an answer.
|
|
169
|
+
* continued — the agent consumed the answer and moved on.
|
|
170
|
+
* tool_completed — the tool an approval gated finished (permission cleared).
|
|
171
|
+
* expired — a decaying heuristic ask aged out.
|
|
172
|
+
* session_advanced— the transcript moved past the block (Stop/PostToolUse clear).
|
|
173
|
+
*/
|
|
174
|
+
export type ResolutionReason = 'answered' | 'continued' | 'tool_completed' | 'expired' | 'session_advanced';
|
|
175
|
+
/**
|
|
176
|
+
* A resolution tombstone. It is recorded BEFORE the open-block view is cleared so
|
|
177
|
+
* a resolved generation can never silently resurrect from a stale lifecycle
|
|
178
|
+
* re-read (the RUSH-1522 stale-flag class): the reconciler suppresses a
|
|
179
|
+
* lifecycle candidate whose generation the tombstone already covers, until the
|
|
180
|
+
* session advances strictly past `sourceCursor`. One tombstone per block id,
|
|
181
|
+
* latest-wins — the block id is stable per session, so this never grows unbounded.
|
|
182
|
+
*/
|
|
183
|
+
export interface AttentionResolution {
|
|
184
|
+
blockId: string;
|
|
185
|
+
/** The generation this tombstone resolved — matched against a fresh candidate's generation. */
|
|
186
|
+
generation: string;
|
|
187
|
+
/** ISO-8601 timestamp of the resolution. */
|
|
188
|
+
resolvedAt: string;
|
|
189
|
+
/** Where in the source the resolved generation sat; the reconciler compares a candidate's cursor against it. */
|
|
190
|
+
sourceCursor?: SourceCursor;
|
|
191
|
+
reason: ResolutionReason;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Canonical generation for a block. A writer that stamped `generation` wins;
|
|
195
|
+
* otherwise the publish timestamp `ts` is the generation, because the feed
|
|
196
|
+
* rewrites `ts` only when a NEW question replaces the old one (an answer/continue
|
|
197
|
+
* update to the same block keeps `ts`). So `ts` changes exactly when the ask
|
|
198
|
+
* changes — which is what a generation must track.
|
|
199
|
+
*/
|
|
200
|
+
export declare function blockGeneration(block: OpenBlock): string;
|
|
201
|
+
/**
|
|
202
|
+
* Canonical source for a block. A writer that stamped `source` wins; otherwise it
|
|
203
|
+
* is derived from `kind`: a declared block is `declared`, a synthetic control card
|
|
204
|
+
* is `system`, everything else (question / notification, written by a harness hook)
|
|
205
|
+
* is `hook`.
|
|
206
|
+
*/
|
|
207
|
+
export declare function blockSource(block: OpenBlock): AttentionSource;
|
|
208
|
+
/**
|
|
209
|
+
* Canonical lifecycle state for a block. A writer that stamped `state` wins;
|
|
210
|
+
* otherwise it is derived from the markers already on the block: `continuedAt`
|
|
211
|
+
* means `continued`, a recorded `answer` means `answered`, and anything else is
|
|
212
|
+
* still `open`. This is the ONE place that turns the historical marker fields into
|
|
213
|
+
* the lifecycle axis, so no consumer re-derives it and drifts.
|
|
214
|
+
*/
|
|
215
|
+
export declare function deriveBlockState(block: OpenBlock): AttentionState;
|
|
216
|
+
/**
|
|
217
|
+
* Record a resolution tombstone (latest-wins per block id). Called from the
|
|
218
|
+
* answer / continue / clear paths BEFORE the open-block view is removed, so the
|
|
219
|
+
* reconciler always has the tombstone by the time the block file is gone.
|
|
220
|
+
*/
|
|
221
|
+
export declare function recordResolution(resolution: AttentionResolution, root?: string): void;
|
|
222
|
+
/** Read the latest resolution tombstone for a block, if one exists. */
|
|
223
|
+
export declare function readResolution(blockId: string, root?: string): AttentionResolution | undefined;
|
|
224
|
+
/** Read every resolution tombstone. Returns them sorted by stable block filename. */
|
|
225
|
+
export declare function listResolutions(root?: string): AttentionResolution[];
|
|
124
226
|
/**
|
|
125
227
|
* Stable block id for a session. One block per session -- a new question
|
|
126
228
|
* replaces the previous one (the agent can only ask one question at a time).
|
|
@@ -155,6 +257,13 @@ export declare function recordAnswer(blockId: string, answer: {
|
|
|
155
257
|
}, root?: string): RecordAnswerResult;
|
|
156
258
|
/** Read the answer record for a block, if one exists. */
|
|
157
259
|
export declare function getAnswerRecord(blockId: string, root?: string): AnswerRecord | undefined;
|
|
260
|
+
/**
|
|
261
|
+
* Release one specific answer claim after its reply rail failed. The compare on
|
|
262
|
+
* `answeredAt` makes this a conditional rollback: it can never erase a newer
|
|
263
|
+
* claimant. The caller supplies the exact pre-claim block/resolution snapshots,
|
|
264
|
+
* restoring the attention lifecycle to the state another surface observed.
|
|
265
|
+
*/
|
|
266
|
+
export declare function rollbackAnswerClaim(blockId: string, answeredAt: string, previousBlock: OpenBlock, previousResolution: AttentionResolution | undefined, root?: string): boolean;
|
|
158
267
|
/** True when the block has already been answered. */
|
|
159
268
|
export declare function isBlockAnswered(blockId: string, root?: string): boolean;
|
|
160
269
|
/**
|
package/dist/lib/feed/feed.js
CHANGED
|
@@ -29,6 +29,80 @@ import { getFeedDir, getUserAgentsDir } from '../state.js';
|
|
|
29
29
|
import { isAdmin, isHighConsequenceAllowed, isKnownOperator } from '../operator.js';
|
|
30
30
|
import { projectKeyFromCwd } from '../project-key.js';
|
|
31
31
|
import { atomicWriteJsonSync } from '../fs-atomic.js';
|
|
32
|
+
function resolutionDir(root) { return path.join(root, 'resolutions'); }
|
|
33
|
+
/**
|
|
34
|
+
* Canonical generation for a block. A writer that stamped `generation` wins;
|
|
35
|
+
* otherwise the publish timestamp `ts` is the generation, because the feed
|
|
36
|
+
* rewrites `ts` only when a NEW question replaces the old one (an answer/continue
|
|
37
|
+
* update to the same block keeps `ts`). So `ts` changes exactly when the ask
|
|
38
|
+
* changes — which is what a generation must track.
|
|
39
|
+
*/
|
|
40
|
+
export function blockGeneration(block) {
|
|
41
|
+
return block.generation ?? block.ts;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Canonical source for a block. A writer that stamped `source` wins; otherwise it
|
|
45
|
+
* is derived from `kind`: a declared block is `declared`, a synthetic control card
|
|
46
|
+
* is `system`, everything else (question / notification, written by a harness hook)
|
|
47
|
+
* is `hook`.
|
|
48
|
+
*/
|
|
49
|
+
export function blockSource(block) {
|
|
50
|
+
if (block.source)
|
|
51
|
+
return block.source;
|
|
52
|
+
switch (block.kind) {
|
|
53
|
+
case 'declared': return 'declared';
|
|
54
|
+
case 'control': return 'system';
|
|
55
|
+
default: return 'hook';
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Canonical lifecycle state for a block. A writer that stamped `state` wins;
|
|
60
|
+
* otherwise it is derived from the markers already on the block: `continuedAt`
|
|
61
|
+
* means `continued`, a recorded `answer` means `answered`, and anything else is
|
|
62
|
+
* still `open`. This is the ONE place that turns the historical marker fields into
|
|
63
|
+
* the lifecycle axis, so no consumer re-derives it and drifts.
|
|
64
|
+
*/
|
|
65
|
+
export function deriveBlockState(block) {
|
|
66
|
+
if (block.state)
|
|
67
|
+
return block.state;
|
|
68
|
+
if (block.continuedAt)
|
|
69
|
+
return 'continued';
|
|
70
|
+
if (block.answer)
|
|
71
|
+
return 'answered';
|
|
72
|
+
return 'open';
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Record a resolution tombstone (latest-wins per block id). Called from the
|
|
76
|
+
* answer / continue / clear paths BEFORE the open-block view is removed, so the
|
|
77
|
+
* reconciler always has the tombstone by the time the block file is gone.
|
|
78
|
+
*/
|
|
79
|
+
export function recordResolution(resolution, root) {
|
|
80
|
+
const dir = resolutionDir(root ?? getFeedDir());
|
|
81
|
+
ensureDir(dir);
|
|
82
|
+
atomicWriteJsonSync(path.join(dir, `${resolution.blockId}.json`), resolution);
|
|
83
|
+
}
|
|
84
|
+
/** Read the latest resolution tombstone for a block, if one exists. */
|
|
85
|
+
export function readResolution(blockId, root) {
|
|
86
|
+
return safeReadJson(path.join(resolutionDir(root ?? getFeedDir()), `${blockId}.json`));
|
|
87
|
+
}
|
|
88
|
+
/** Read every resolution tombstone. Returns them sorted by stable block filename. */
|
|
89
|
+
export function listResolutions(root) {
|
|
90
|
+
const dir = resolutionDir(root ?? getFeedDir());
|
|
91
|
+
let names;
|
|
92
|
+
try {
|
|
93
|
+
names = fs.readdirSync(dir);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
const out = [];
|
|
99
|
+
for (const name of names.filter(n => n.endsWith('.json')).sort()) {
|
|
100
|
+
const parsed = safeReadJson(path.join(dir, name));
|
|
101
|
+
if (parsed?.blockId && parsed.generation && parsed.reason)
|
|
102
|
+
out.push(parsed);
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
32
106
|
/**
|
|
33
107
|
* Stable block id for a session. One block per session -- a new question
|
|
34
108
|
* replaces the previous one (the agent can only ask one question at a time).
|
|
@@ -131,9 +205,20 @@ export function recordAnswer(blockId, answer, root) {
|
|
|
131
205
|
}
|
|
132
206
|
throw err;
|
|
133
207
|
}
|
|
134
|
-
// Marker created successfully -- mirror the answer into the block file
|
|
208
|
+
// Marker created successfully -- mirror the answer into the block file and
|
|
209
|
+
// advance the lifecycle to `answered`. The resolution tombstone is written
|
|
210
|
+
// first, so if a stale lifecycle re-read races the block-file update the
|
|
211
|
+
// reconciler already refuses to resurrect this generation.
|
|
135
212
|
if (block) {
|
|
213
|
+
recordResolution({
|
|
214
|
+
blockId,
|
|
215
|
+
generation: blockGeneration(block),
|
|
216
|
+
resolvedAt: record.answeredAt,
|
|
217
|
+
sourceCursor: block.sourceCursor,
|
|
218
|
+
reason: 'answered',
|
|
219
|
+
}, dir);
|
|
136
220
|
block.answer = record;
|
|
221
|
+
block.state = 'answered';
|
|
137
222
|
publishBlock(block, dir);
|
|
138
223
|
}
|
|
139
224
|
return { ok: true };
|
|
@@ -142,6 +227,37 @@ export function recordAnswer(blockId, answer, root) {
|
|
|
142
227
|
export function getAnswerRecord(blockId, root) {
|
|
143
228
|
return safeReadJson(path.join(answeredDir(root ?? getFeedDir()), `${blockId}.json`));
|
|
144
229
|
}
|
|
230
|
+
/**
|
|
231
|
+
* Release one specific answer claim after its reply rail failed. The compare on
|
|
232
|
+
* `answeredAt` makes this a conditional rollback: it can never erase a newer
|
|
233
|
+
* claimant. The caller supplies the exact pre-claim block/resolution snapshots,
|
|
234
|
+
* restoring the attention lifecycle to the state another surface observed.
|
|
235
|
+
*/
|
|
236
|
+
export function rollbackAnswerClaim(blockId, answeredAt, previousBlock, previousResolution, root) {
|
|
237
|
+
const dir = root ?? getFeedDir();
|
|
238
|
+
const marker = path.join(answeredDir(dir), `${blockId}.json`);
|
|
239
|
+
const current = safeReadJson(marker);
|
|
240
|
+
if (!current || current.answeredAt !== answeredAt)
|
|
241
|
+
return false;
|
|
242
|
+
// Restore while the O_EXCL marker still excludes every other claimant. The
|
|
243
|
+
// marker is removed LAST; once another writer can win recordAnswer, this
|
|
244
|
+
// rollback has no state left to overwrite.
|
|
245
|
+
publishBlock(previousBlock, dir);
|
|
246
|
+
const resolutionFile = path.join(resolutionDir(dir), `${blockId}.json`);
|
|
247
|
+
if (previousResolution)
|
|
248
|
+
recordResolution(previousResolution, dir);
|
|
249
|
+
else {
|
|
250
|
+
try {
|
|
251
|
+
fs.unlinkSync(resolutionFile);
|
|
252
|
+
}
|
|
253
|
+
catch (error) {
|
|
254
|
+
if (error.code !== 'ENOENT')
|
|
255
|
+
throw error;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
fs.unlinkSync(marker);
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
145
261
|
/** True when the block has already been answered. */
|
|
146
262
|
export function isBlockAnswered(blockId, root) {
|
|
147
263
|
return fs.existsSync(path.join(answeredDir(root ?? getFeedDir()), `${blockId}.json`));
|
|
@@ -190,7 +306,16 @@ export function recordContinued(blockId, root) {
|
|
|
190
306
|
const block = readBlock(blockId, dir);
|
|
191
307
|
if (!block)
|
|
192
308
|
return;
|
|
193
|
-
|
|
309
|
+
const now = new Date().toISOString();
|
|
310
|
+
block.continuedAt = now;
|
|
311
|
+
block.state = 'continued';
|
|
312
|
+
recordResolution({
|
|
313
|
+
blockId,
|
|
314
|
+
generation: blockGeneration(block),
|
|
315
|
+
resolvedAt: now,
|
|
316
|
+
sourceCursor: block.sourceCursor,
|
|
317
|
+
reason: 'continued',
|
|
318
|
+
}, dir);
|
|
194
319
|
publishBlock(block, dir);
|
|
195
320
|
}
|
|
196
321
|
/** Mark a decision-class block as hard-parked (no safe default existed). */
|
|
@@ -260,13 +385,20 @@ export function buildDeclaredBlock(agent, input) {
|
|
|
260
385
|
.filter(Boolean)
|
|
261
386
|
.map((label) => ({ label }));
|
|
262
387
|
const project = projectKeyFromCwd(agent.cwd);
|
|
388
|
+
const ts = input.ts ?? new Date().toISOString();
|
|
263
389
|
return {
|
|
264
390
|
blockId: blockIdForSession(agent.sessionId),
|
|
265
391
|
sessionId: agent.sessionId,
|
|
266
392
|
mailboxId: agent.mailboxId,
|
|
267
393
|
host: agent.host,
|
|
268
394
|
runtime: agent.runtime,
|
|
269
|
-
ts
|
|
395
|
+
ts,
|
|
396
|
+
// A declared block is an explicit, agent-raised attention record: it opens the
|
|
397
|
+
// lifecycle here, sourced `declared`, with `ts` as its generation so a later
|
|
398
|
+
// `--blocked` in the same session mints a fresh generation past any tombstone.
|
|
399
|
+
generation: ts,
|
|
400
|
+
source: 'declared',
|
|
401
|
+
state: 'open',
|
|
270
402
|
kind: 'declared',
|
|
271
403
|
questions: [{ text, header: 'Needs you', ...(options.length ? { options } : {}) }],
|
|
272
404
|
blockClass: input.safeDefault ? 'approval' : 'decision',
|
|
@@ -337,6 +469,25 @@ export function listAskStats(root) {
|
|
|
337
469
|
/** Remove a block record and its lifecycle sidecars. Returns true if the file was deleted. */
|
|
338
470
|
export function removeBlock(blockId, root) {
|
|
339
471
|
const dir = root ?? getFeedDir();
|
|
472
|
+
// Record a resolution tombstone BEFORE the block file and answered marker are
|
|
473
|
+
// gone, so a stale lifecycle re-read of the same session cannot resurrect this
|
|
474
|
+
// cleared generation. The reason names how it closed: an answered marker means
|
|
475
|
+
// the operator answered, `continuedAt` means the agent moved on, otherwise the
|
|
476
|
+
// transcript advanced past it (a Stop/PostToolUse clear). The tombstone itself
|
|
477
|
+
// is deliberately NOT cleared — it must outlive the block to do its job.
|
|
478
|
+
const block = readBlock(blockId, dir);
|
|
479
|
+
if (block) {
|
|
480
|
+
const reason = isBlockAnswered(blockId, dir)
|
|
481
|
+
? 'answered'
|
|
482
|
+
: block.continuedAt ? 'continued' : 'session_advanced';
|
|
483
|
+
recordResolution({
|
|
484
|
+
blockId,
|
|
485
|
+
generation: blockGeneration(block),
|
|
486
|
+
resolvedAt: new Date().toISOString(),
|
|
487
|
+
sourceCursor: block.sourceCursor,
|
|
488
|
+
reason,
|
|
489
|
+
}, dir);
|
|
490
|
+
}
|
|
340
491
|
clearBlockLifecycle(blockId, dir);
|
|
341
492
|
try {
|
|
342
493
|
fs.unlinkSync(blockPath(dir, blockId));
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ActiveSession } from '../session/active.js';
|
|
2
|
+
import type { PullRequestAttentionSignal } from './attention.js';
|
|
3
|
+
export declare const PR_STATUS_TTL_MS = 45000;
|
|
4
|
+
export declare const PR_STATUS_FIELDS = "number,title,state,isDraft,reviewDecision,mergeable,statusCheckRollup";
|
|
5
|
+
export interface PullRequestStatus extends PullRequestAttentionSignal {
|
|
6
|
+
statusCheckRollup?: unknown[];
|
|
7
|
+
}
|
|
8
|
+
/** CLI-owned bounded-TTL source shared by feed attention and PR-board consumers. */
|
|
9
|
+
export declare function readPullRequestStatus(session: ActiveSession, options?: {
|
|
10
|
+
nowMs?: number;
|
|
11
|
+
ttlMs?: number;
|
|
12
|
+
}): Promise<PullRequestStatus | undefined>;
|
|
13
|
+
export declare function projectPullRequestBoard(sessions: ActiveSession[]): Promise<PullRequestStatus[]>;
|
|
14
|
+
export declare function resetPullRequestStatusCache(): void;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
const execFileAsync = promisify(execFile);
|
|
4
|
+
export const PR_STATUS_TTL_MS = 45_000;
|
|
5
|
+
export const PR_STATUS_FIELDS = 'number,title,state,isDraft,reviewDecision,mergeable,statusCheckRollup';
|
|
6
|
+
const cache = new Map();
|
|
7
|
+
function needsHuman(value) {
|
|
8
|
+
if (value.state !== 'OPEN' || value.isDraft)
|
|
9
|
+
return false;
|
|
10
|
+
const checks = value.statusCheckRollup ?? [];
|
|
11
|
+
const checksSettled = checks.every((check) => {
|
|
12
|
+
const row = check;
|
|
13
|
+
return row.conclusion === 'SUCCESS' || row.conclusion === 'NEUTRAL' || row.status === 'COMPLETED';
|
|
14
|
+
});
|
|
15
|
+
return value.reviewDecision !== 'APPROVED' || (value.mergeable === 'MERGEABLE' && checksSettled);
|
|
16
|
+
}
|
|
17
|
+
/** CLI-owned bounded-TTL source shared by feed attention and PR-board consumers. */
|
|
18
|
+
export async function readPullRequestStatus(session, options = {}) {
|
|
19
|
+
const ref = session.pr?.url ?? session.pr?.number;
|
|
20
|
+
if (!ref || !session.cwd)
|
|
21
|
+
return undefined;
|
|
22
|
+
const key = `${session.cwd}\0${String(ref)}`;
|
|
23
|
+
const now = options.nowMs ?? Date.now();
|
|
24
|
+
const hit = cache.get(key);
|
|
25
|
+
if (hit && hit.expiresAt > now)
|
|
26
|
+
return hit.value;
|
|
27
|
+
try {
|
|
28
|
+
const { stdout } = await execFileAsync('gh', ['pr', 'view', String(ref), '--json', PR_STATUS_FIELDS], {
|
|
29
|
+
cwd: session.cwd,
|
|
30
|
+
timeout: 15_000,
|
|
31
|
+
maxBuffer: 1024 * 1024,
|
|
32
|
+
});
|
|
33
|
+
const raw = JSON.parse(stdout);
|
|
34
|
+
const value = { ...raw, url: session.pr?.url, needsHuman: needsHuman(raw) };
|
|
35
|
+
cache.set(key, { expiresAt: now + (options.ttlMs ?? PR_STATUS_TTL_MS), value });
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
cache.set(key, { expiresAt: now + (options.ttlMs ?? PR_STATUS_TTL_MS) });
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export async function projectPullRequestBoard(sessions) {
|
|
44
|
+
const rows = await Promise.all(sessions.map((session) => readPullRequestStatus(session)));
|
|
45
|
+
return rows.filter((row) => row !== undefined);
|
|
46
|
+
}
|
|
47
|
+
export function resetPullRequestStatusCache() { cache.clear(); }
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { type SessionWatchEnvelope, type SessionWatchRow, type SessionWatchScopeStatus } from '../session/watch.js';
|
|
2
|
+
import { type AttentionItem } from './attention.js';
|
|
3
|
+
import { type ActivityEvent } from './activity.js';
|
|
4
|
+
export declare const FEED_WATCH_VERSION: 1;
|
|
5
|
+
type Base = {
|
|
6
|
+
v: 1;
|
|
7
|
+
type: string;
|
|
8
|
+
streamId: string;
|
|
9
|
+
sequence: number;
|
|
10
|
+
scope: string;
|
|
11
|
+
};
|
|
12
|
+
export type FeedWatchEnvelope = Base & {
|
|
13
|
+
type: 'reset';
|
|
14
|
+
capturedAt: number;
|
|
15
|
+
agents: SessionWatchRow[];
|
|
16
|
+
attention: AttentionItem[];
|
|
17
|
+
} | Base & {
|
|
18
|
+
type: 'agent.upsert';
|
|
19
|
+
rowKey: string;
|
|
20
|
+
agent: SessionWatchRow;
|
|
21
|
+
} | Base & {
|
|
22
|
+
type: 'attention.upsert';
|
|
23
|
+
rowKey: string;
|
|
24
|
+
attention: AttentionItem;
|
|
25
|
+
} | Base & {
|
|
26
|
+
type: 'attention.remove';
|
|
27
|
+
rowKey: string;
|
|
28
|
+
} | Base & {
|
|
29
|
+
type: 'activity.append';
|
|
30
|
+
event: ActivityEvent;
|
|
31
|
+
} | Base & {
|
|
32
|
+
type: 'scope';
|
|
33
|
+
capturedAt: number;
|
|
34
|
+
status: SessionWatchScopeStatus;
|
|
35
|
+
reason?: string;
|
|
36
|
+
} | Base & {
|
|
37
|
+
type: 'heartbeat';
|
|
38
|
+
capturedAt: number;
|
|
39
|
+
};
|
|
40
|
+
type FeedWatchPayload = FeedWatchEnvelope extends infer Envelope ? Envelope extends FeedWatchEnvelope ? Omit<Envelope, 'v' | 'streamId' | 'sequence'> : never : never;
|
|
41
|
+
export declare class FeedWatchState {
|
|
42
|
+
readonly streamId: string;
|
|
43
|
+
private sequence;
|
|
44
|
+
constructor(streamId?: `${string}-${string}-${string}-${string}-${string}`);
|
|
45
|
+
emit(event: FeedWatchPayload): FeedWatchEnvelope;
|
|
46
|
+
}
|
|
47
|
+
export declare function projectSessionEnvelope(event: SessionWatchEnvelope, state: FeedWatchState): Promise<FeedWatchEnvelope[]>;
|
|
48
|
+
export declare function watchLocalFeed(options: {
|
|
49
|
+
scope: string;
|
|
50
|
+
signal: AbortSignal;
|
|
51
|
+
emit: (event: FeedWatchEnvelope) => void;
|
|
52
|
+
activityPollMs?: number;
|
|
53
|
+
}): Promise<void>;
|
|
54
|
+
export declare function watchFleetFeed(options: {
|
|
55
|
+
signal: AbortSignal;
|
|
56
|
+
emit: (event: FeedWatchEnvelope) => void;
|
|
57
|
+
reconnectMs?: number;
|
|
58
|
+
}): Promise<void>;
|
|
59
|
+
export {};
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { createInterface } from 'node:readline';
|
|
4
|
+
import { loadDevices, isDialableDevice } from '../devices/registry.js';
|
|
5
|
+
import { deviceIdentityArgs, sshTargetFor } from '../devices/connect.js';
|
|
6
|
+
import { machineId, normalizeHost } from '../machine-id.js';
|
|
7
|
+
import { SSH_OPTS, controlOpts, shellQuote } from '../ssh-exec.js';
|
|
8
|
+
import { buildWindowsAgentsCommand, remoteShellFor } from '../hosts/remote-cmd.js';
|
|
9
|
+
import { watchLocalSessions } from '../session/watch.js';
|
|
10
|
+
import { readBlock, readResolution, blockIdForSession } from './feed.js';
|
|
11
|
+
import { reconcileAttention } from './attention.js';
|
|
12
|
+
import { readRecentActivity } from './activity.js';
|
|
13
|
+
import { readPullRequestStatus } from './pr-status.js';
|
|
14
|
+
export const FEED_WATCH_VERSION = 1;
|
|
15
|
+
export class FeedWatchState {
|
|
16
|
+
streamId;
|
|
17
|
+
sequence = 0;
|
|
18
|
+
constructor(streamId = randomUUID()) { this.streamId = streamId; }
|
|
19
|
+
emit(event) {
|
|
20
|
+
return { v: 1, streamId: this.streamId, sequence: ++this.sequence, ...event };
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
async function attentionFor(agent) {
|
|
24
|
+
if (!agent.sessionId)
|
|
25
|
+
return undefined;
|
|
26
|
+
const blockId = blockIdForSession(agent.sessionId);
|
|
27
|
+
// ActiveSession.host names the terminal app; the feed contract's host is the
|
|
28
|
+
// device scope. Normalize only the reconciler input so lifecycle/PR keys are
|
|
29
|
+
// routable across the fleet while the projected agent row stays compatible.
|
|
30
|
+
const session = { ...agent, host: agent.sourceDevice, viewingIn: undefined };
|
|
31
|
+
return reconcileAttention({
|
|
32
|
+
block: readBlock(blockId), session,
|
|
33
|
+
resolution: readResolution(blockId),
|
|
34
|
+
pullRequest: await readPullRequestStatus(session), nowMs: Date.now(),
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
export async function projectSessionEnvelope(event, state) {
|
|
38
|
+
if (event.type === 'reset') {
|
|
39
|
+
const attention = (await Promise.all(event.rows.map(attentionFor))).filter((item) => item !== undefined);
|
|
40
|
+
return [state.emit({ type: 'reset', capturedAt: event.capturedAt, scope: event.scope, agents: event.rows, attention })];
|
|
41
|
+
}
|
|
42
|
+
if (event.type === 'upsert') {
|
|
43
|
+
const attention = await attentionFor(event.row);
|
|
44
|
+
return [
|
|
45
|
+
state.emit({ type: 'agent.upsert', scope: event.scope, rowKey: event.rowKey, agent: event.row }),
|
|
46
|
+
attention
|
|
47
|
+
? state.emit({ type: 'attention.upsert', scope: event.scope, rowKey: event.rowKey, attention })
|
|
48
|
+
: state.emit({ type: 'attention.remove', scope: event.scope, rowKey: event.rowKey }),
|
|
49
|
+
];
|
|
50
|
+
}
|
|
51
|
+
if (event.type === 'remove')
|
|
52
|
+
return [state.emit({ type: 'attention.remove', scope: event.scope, rowKey: event.rowKey })];
|
|
53
|
+
if (event.type === 'scope')
|
|
54
|
+
return [state.emit({ type: 'scope', capturedAt: event.capturedAt, scope: event.scope, status: event.status, ...(event.reason ? { reason: event.reason } : {}) })];
|
|
55
|
+
return [state.emit({ type: 'heartbeat', capturedAt: event.capturedAt, scope: event.scope })];
|
|
56
|
+
}
|
|
57
|
+
export async function watchLocalFeed(options) {
|
|
58
|
+
const state = new FeedWatchState();
|
|
59
|
+
let activityCursor = Date.now();
|
|
60
|
+
const agents = new Map();
|
|
61
|
+
const attention = new Map();
|
|
62
|
+
let pending = Promise.resolve();
|
|
63
|
+
const reconcileRows = async () => {
|
|
64
|
+
for (const [rowKey, agent] of agents) {
|
|
65
|
+
const item = await attentionFor(agent);
|
|
66
|
+
const next = item ? JSON.stringify(item) : '';
|
|
67
|
+
if (attention.get(rowKey) === next)
|
|
68
|
+
continue;
|
|
69
|
+
attention.set(rowKey, next);
|
|
70
|
+
options.emit(item
|
|
71
|
+
? state.emit({ type: 'attention.upsert', scope: options.scope, rowKey, attention: item })
|
|
72
|
+
: state.emit({ type: 'attention.remove', scope: options.scope, rowKey }));
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
const activityTimer = setInterval(() => {
|
|
76
|
+
pending = pending.then(async () => {
|
|
77
|
+
const events = readRecentActivity({ sinceMs: activityCursor + 1 }).reverse();
|
|
78
|
+
for (const event of events) {
|
|
79
|
+
activityCursor = Math.max(activityCursor, Date.parse(event.ts));
|
|
80
|
+
options.emit(state.emit({ type: 'activity.append', scope: options.scope, event }));
|
|
81
|
+
}
|
|
82
|
+
await reconcileRows();
|
|
83
|
+
});
|
|
84
|
+
}, options.activityPollMs ?? 500);
|
|
85
|
+
const stopActivity = () => clearInterval(activityTimer);
|
|
86
|
+
options.signal.addEventListener('abort', stopActivity, { once: true });
|
|
87
|
+
try {
|
|
88
|
+
await watchLocalSessions({ scope: options.scope, signal: options.signal, emit: (event) => {
|
|
89
|
+
if (event.type === 'reset') {
|
|
90
|
+
agents.clear();
|
|
91
|
+
for (const row of event.rows)
|
|
92
|
+
agents.set(row.rowKey, row);
|
|
93
|
+
}
|
|
94
|
+
else if (event.type === 'upsert')
|
|
95
|
+
agents.set(event.rowKey, event.row);
|
|
96
|
+
else if (event.type === 'remove') {
|
|
97
|
+
agents.delete(event.rowKey);
|
|
98
|
+
attention.delete(event.rowKey);
|
|
99
|
+
}
|
|
100
|
+
pending = pending.then(() => projectSessionEnvelope(event, state)).then((events) => {
|
|
101
|
+
for (const projected of events) {
|
|
102
|
+
if (projected.type === 'reset') {
|
|
103
|
+
attention.clear();
|
|
104
|
+
for (const row of projected.agents)
|
|
105
|
+
attention.set(row.rowKey, '');
|
|
106
|
+
for (const item of projected.attention) {
|
|
107
|
+
const row = projected.agents.find((agent) => agent.sessionId === item.sessionId);
|
|
108
|
+
if (row)
|
|
109
|
+
attention.set(row.rowKey, JSON.stringify(item));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
else if (projected.type === 'attention.upsert')
|
|
113
|
+
attention.set(projected.rowKey, JSON.stringify(projected.attention));
|
|
114
|
+
else if (projected.type === 'attention.remove')
|
|
115
|
+
attention.set(projected.rowKey, '');
|
|
116
|
+
options.emit(projected);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
} });
|
|
120
|
+
await pending;
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
stopActivity();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function remoteFeedWatchCommand(os) {
|
|
127
|
+
const args = ['feed', 'watch', '--json', '--local'];
|
|
128
|
+
return remoteShellFor(os) === 'powershell'
|
|
129
|
+
? buildWindowsAgentsCommand({ args })
|
|
130
|
+
: `bash -lc ${shellQuote(`agents ${args.map(shellQuote).join(' ')}`)}`;
|
|
131
|
+
}
|
|
132
|
+
export async function watchFleetFeed(options) {
|
|
133
|
+
const coordinator = new FeedWatchState();
|
|
134
|
+
const forward = (event) => {
|
|
135
|
+
const { v: _v, streamId: peerStreamId, sequence: peerSequence, ...payload } = event;
|
|
136
|
+
options.emit(coordinator.emit({ ...payload, peerStreamId, peerSequence }));
|
|
137
|
+
};
|
|
138
|
+
const local = watchLocalFeed({ scope: machineId(), signal: options.signal, emit: forward });
|
|
139
|
+
let devices;
|
|
140
|
+
try {
|
|
141
|
+
devices = await loadDevices();
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
await local;
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const self = machineId();
|
|
148
|
+
const peers = Object.values(devices).filter((device) => isDialableDevice(device) && normalizeHost(device.name) !== self && ['windows', 'linux', 'macos'].includes(device.platform));
|
|
149
|
+
const tasks = peers.map(async (device) => {
|
|
150
|
+
const scope = normalizeHost(device.name);
|
|
151
|
+
while (!options.signal.aborted) {
|
|
152
|
+
let target;
|
|
153
|
+
try {
|
|
154
|
+
target = sshTargetFor(device);
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
options.emit(coordinator.emit({ type: 'scope', capturedAt: Date.now(), scope, status: 'unavailable', reason: String(error) }));
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
const child = spawn('ssh', [...SSH_OPTS, ...controlOpts(), ...deviceIdentityArgs(device), target, remoteFeedWatchCommand(device.platform)], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
161
|
+
const stop = () => child.kill('SIGTERM');
|
|
162
|
+
options.signal.addEventListener('abort', stop, { once: true });
|
|
163
|
+
const reader = createInterface({ input: child.stdout });
|
|
164
|
+
reader.on('line', (line) => { try {
|
|
165
|
+
const event = JSON.parse(line);
|
|
166
|
+
if (event.v === 1)
|
|
167
|
+
forward(event);
|
|
168
|
+
}
|
|
169
|
+
catch { /* protocol only */ } });
|
|
170
|
+
const code = await new Promise((resolve) => { child.once('error', () => resolve(null)); child.once('close', resolve); });
|
|
171
|
+
reader.close();
|
|
172
|
+
options.signal.removeEventListener('abort', stop);
|
|
173
|
+
if (options.signal.aborted)
|
|
174
|
+
break;
|
|
175
|
+
options.emit(coordinator.emit({ type: 'scope', capturedAt: Date.now(), scope, status: 'unavailable', reason: code == null ? 'ssh failed' : `ssh exited ${code}` }));
|
|
176
|
+
await new Promise((resolve) => { const timer = setTimeout(resolve, options.reconnectMs ?? 2_000); options.signal.addEventListener('abort', () => { clearTimeout(timer); resolve(); }, { once: true }); });
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
await Promise.all([local, ...tasks]);
|
|
180
|
+
}
|
|
@@ -58,6 +58,12 @@ export function captureFleet(prev, inputs) {
|
|
|
58
58
|
if (prev?.discovery && Object.keys(prev.discovery).length > 0) {
|
|
59
59
|
manifest.discovery = { ...prev.discovery };
|
|
60
60
|
}
|
|
61
|
+
// Dismissals are operator state, not live state — a capture must never wipe
|
|
62
|
+
// them (fleet.ignored syncs; losing it re-suggests every dismissed node
|
|
63
|
+
// fleet-wide). Carry forward verbatim, same contract as `discovery`.
|
|
64
|
+
if (prev?.ignored && prev.ignored.length > 0) {
|
|
65
|
+
manifest.ignored = prev.ignored.map((e) => ({ ...e }));
|
|
66
|
+
}
|
|
61
67
|
const bundles = inputs.secretsBundles ?? prev?.secrets?.bundles;
|
|
62
68
|
if (bundles && bundles.length > 0)
|
|
63
69
|
manifest.secrets = { bundles: [...bundles].sort() };
|