@phnx-labs/agents-cli 1.22.66 → 1.22.69
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 +56 -0
- package/README.md +21 -8
- package/dist/bootstrap.js +6 -3
- package/dist/commands/browser.js +46 -21
- package/dist/commands/daemon-test-harness.d.ts +1 -1
- package/dist/commands/daemon-test-harness.js +2 -2
- package/dist/commands/daemon.js +21 -22
- package/dist/commands/exec.js +50 -0
- package/dist/commands/feed.js +20 -7
- package/dist/commands/monitors.js +5 -2
- package/dist/commands/projects.d.ts +26 -6
- package/dist/commands/projects.js +55 -22
- package/dist/commands/repo.js +57 -19
- package/dist/commands/resume.d.ts +16 -0
- package/dist/commands/resume.js +41 -8
- package/dist/commands/routines.js +42 -21
- package/dist/commands/send.js +29 -2
- package/dist/commands/sessions-inject.d.ts +58 -0
- package/dist/commands/sessions-inject.js +143 -7
- package/dist/commands/sessions-optimize.js +1 -1
- package/dist/commands/sessions-picker.js +1 -0
- package/dist/commands/sessions.js +4 -11
- package/dist/commands/share.d.ts +5 -3
- package/dist/commands/share.js +73 -20
- package/dist/commands/ssh.js +205 -2
- package/dist/lib/accounting/account-pool-collect.d.ts +6 -4
- package/dist/lib/accounting/account-pool-collect.js +6 -4
- package/dist/lib/accounting/usage-ingest.js +4 -2
- package/dist/lib/accounting/usage-sync.d.ts +38 -93
- package/dist/lib/accounting/usage-sync.js +66 -210
- package/dist/lib/accounting/usage.d.ts +18 -5
- package/dist/lib/accounting/usage.js +165 -20
- package/dist/lib/auth-health.d.ts +8 -0
- package/dist/lib/auth-health.js +4 -4
- package/dist/lib/boot-profile.d.ts +14 -0
- package/dist/lib/boot-profile.js +66 -0
- package/dist/lib/browser/caller-identity.d.ts +12 -0
- package/dist/lib/browser/caller-identity.js +19 -0
- package/dist/lib/browser/ipc.d.ts +37 -32
- package/dist/lib/browser/ipc.js +146 -94
- package/dist/lib/browser/task-index.d.ts +10 -2
- package/dist/lib/browser/task-index.js +22 -3
- package/dist/lib/channels/providers/desktop.d.ts +5 -4
- package/dist/lib/channels/providers/desktop.js +5 -4
- package/dist/lib/claude-account-token.js +108 -4
- package/dist/lib/daemon/account-state-daemon-service.d.ts +49 -9
- package/dist/lib/daemon/account-state-daemon-service.js +81 -18
- package/dist/lib/daemon/auth-sync-service.d.ts +4 -4
- package/dist/lib/daemon/auth-sync-service.js +17 -6
- package/dist/lib/daemon/catchup-service.d.ts +51 -0
- package/dist/lib/daemon/catchup-service.js +51 -0
- package/dist/lib/daemon/daemon.d.ts +12 -22
- package/dist/lib/daemon/daemon.js +463 -176
- package/dist/lib/daemon/runner.js +2 -0
- package/dist/lib/daemon/service.d.ts +22 -4
- package/dist/lib/daemon/service.js +2 -2
- package/dist/lib/daemon/supervisor.d.ts +55 -15
- package/dist/lib/daemon/supervisor.js +119 -29
- package/dist/lib/daemon/usage-sync-service.d.ts +4 -6
- package/dist/lib/daemon/usage-sync-service.js +22 -18
- package/dist/lib/daemon-health.js +36 -31
- package/dist/lib/daemon-services.d.ts +1 -1
- package/dist/lib/daemon-services.js +12 -2
- package/dist/lib/daemon-ticks.d.ts +9 -6
- package/dist/lib/daemon-ticks.js +14 -8
- package/dist/lib/devices/health.d.ts +38 -2
- package/dist/lib/devices/health.js +43 -5
- package/dist/lib/devices/registry.js +2 -0
- package/dist/lib/devices/worker-pick.d.ts +1 -1
- package/dist/lib/devices/worker-pick.js +4 -1
- package/dist/lib/exec.js +15 -0
- package/dist/lib/feed/watch.d.ts +3 -0
- package/dist/lib/feed/watch.js +13 -3
- package/dist/lib/feed-broadcast.d.ts +64 -5
- package/dist/lib/feed-broadcast.js +124 -22
- package/dist/lib/fleet-shared-repo-sync.d.ts +36 -0
- package/dist/lib/fleet-shared-repo-sync.js +333 -0
- package/dist/lib/fleet-shared-state.d.ts +38 -0
- package/dist/lib/fleet-shared-state.js +105 -0
- package/dist/lib/hosts/remote-cmd.d.ts +2 -0
- package/dist/lib/hosts/remote-cmd.js +12 -3
- package/dist/lib/lock-compromise.d.ts +8 -0
- package/dist/lib/lock-compromise.js +12 -0
- package/dist/lib/monitors/engine.d.ts +2 -1
- package/dist/lib/monitors/engine.js +27 -2
- package/dist/lib/monitors/sources/command.js +13 -3
- package/dist/lib/monitors/sources/failure.d.ts +32 -0
- package/dist/lib/monitors/sources/failure.js +52 -0
- package/dist/lib/monitors/sources/types.d.ts +9 -0
- package/dist/lib/owner-message.d.ts +12 -0
- package/dist/lib/owner-message.js +44 -0
- package/dist/lib/refresh-coordinator.js +2 -0
- package/dist/lib/run-trace-sync.d.ts +28 -0
- package/dist/lib/run-trace-sync.js +99 -0
- package/dist/lib/secrets/filestore.d.ts +4 -0
- package/dist/lib/secrets/filestore.js +164 -3
- package/dist/lib/secrets/push.d.ts +10 -0
- package/dist/lib/secrets/push.js +86 -7
- package/dist/lib/secrets/remote.d.ts +18 -6
- package/dist/lib/secrets/remote.js +29 -4
- package/dist/lib/secrets/reserved-sync.d.ts +28 -27
- package/dist/lib/secrets/reserved-sync.js +119 -101
- package/dist/lib/session/active.d.ts +13 -1
- package/dist/lib/session/active.js +5 -0
- package/dist/lib/session/actor-sidecar.d.ts +12 -0
- package/dist/lib/session/actor-sidecar.js +2 -0
- package/dist/lib/session/db.d.ts +16 -2
- package/dist/lib/session/db.js +73 -22
- package/dist/lib/session/discover.d.ts +12 -3
- package/dist/lib/session/discover.js +187 -26
- package/dist/lib/session/linear.d.ts +13 -0
- package/dist/lib/session/linear.js +44 -0
- package/dist/lib/session/live-metadata.js +1 -0
- package/dist/lib/session/parse.js +2 -3
- package/dist/lib/session/prompt.d.ts +17 -0
- package/dist/lib/session/prompt.js +35 -0
- package/dist/lib/session/recovery.d.ts +43 -6
- package/dist/lib/session/recovery.js +80 -10
- package/dist/lib/session/remote/remote-list.d.ts +17 -1
- package/dist/lib/session/remote/remote-list.js +29 -4
- package/dist/lib/session/remote/watch.d.ts +25 -2
- package/dist/lib/session/remote/watch.js +188 -11
- package/dist/lib/session/session-cache.d.ts +2 -1
- package/dist/lib/session/session-cache.js +1 -0
- package/dist/lib/session/state.js +11 -13
- package/dist/lib/session/types.d.ts +2 -0
- package/dist/lib/share/backend.d.ts +2 -2
- package/dist/lib/share/backend.js +20 -9
- package/dist/lib/share/delete.d.ts +5 -1
- package/dist/lib/share/delete.js +7 -2
- package/dist/lib/share/http-error.d.ts +52 -0
- package/dist/lib/share/http-error.js +65 -0
- package/dist/lib/share/publish.d.ts +67 -11
- package/dist/lib/share/publish.js +98 -16
- package/dist/lib/share/worker-template.js +105 -9
- package/dist/lib/smart-launch.js +27 -4
- package/dist/lib/ssh-exec.d.ts +2 -0
- package/dist/lib/ssh-exec.js +20 -4
- package/dist/lib/storage/index.d.ts +14 -0
- package/dist/lib/storage/index.js +14 -0
- package/dist/lib/storage/selection.d.ts +48 -0
- package/dist/lib/storage/selection.js +39 -0
- package/dist/lib/storage/visibility.d.ts +82 -0
- package/dist/lib/storage/visibility.js +99 -0
- package/dist/lib/teams/agents.js +3 -1
- package/dist/lib/teams/placement-probe.js +1 -0
- package/dist/lib/teams/registry.js +2 -0
- package/dist/lib/teams/scheduler.d.ts +8 -1
- package/dist/lib/teams/scheduler.js +4 -1
- package/dist/lib/testdata/daemon-health-writer.d.ts +1 -0
- package/dist/lib/testdata/daemon-health-writer.js +8 -0
- package/dist/lib/traces/backend.js +13 -2
- package/dist/lib/traces/sync.d.ts +7 -0
- package/dist/lib/traces/sync.js +9 -0
- package/dist/lib/usage-refresh.d.ts +8 -2
- package/dist/lib/usage-refresh.js +3 -3
- package/dist/lib/worktree/held.d.ts +166 -0
- package/dist/lib/worktree/held.js +368 -0
- package/package.json +2 -2
- package/dist/lib/account-state-service.d.ts +0 -21
- package/dist/lib/account-state-service.js +0 -60
|
@@ -38,7 +38,8 @@ import { isOwnerAlias, readOwnerDest, resolveSendEnvelope, deliverEnvelope } fro
|
|
|
38
38
|
import { lookupTransport } from './channels/resolve.js';
|
|
39
39
|
import { registerBuiltinProviders } from './channels/providers/index.js';
|
|
40
40
|
import { sendToOwner } from './notify.js';
|
|
41
|
-
import { linearIssueUrl } from './session/linear.js';
|
|
41
|
+
import { linearIssueUrl, linearIssueKeys } from './session/linear.js';
|
|
42
|
+
import { isValidMailboxId } from './mailbox.js';
|
|
42
43
|
import { forwardOwnerNotifyToPeer } from './channels/owner-forward.js';
|
|
43
44
|
const LEVEL_RANK = { milestone: 0, important: 1 };
|
|
44
45
|
/** Parse a `--level` value; anything unrecognized is a usage error, not a default. */
|
|
@@ -145,6 +146,27 @@ export function shortSessionChunk(session) {
|
|
|
145
146
|
const chunk = hex.replace(/[^a-f0-9]/g, '').slice(0, 8);
|
|
146
147
|
return chunk || undefined;
|
|
147
148
|
}
|
|
149
|
+
/**
|
|
150
|
+
* Tap-to-view link for the session behind a post: the addressable console page
|
|
151
|
+
* ({@link https://prix.dev/console/sessions/<id>}, prix/web). The footer already
|
|
152
|
+
* carries a short session crumb for disambiguation; this rides the link trail so
|
|
153
|
+
* the owner can open the full transcript straight from an iMessage broadcast
|
|
154
|
+
* instead of hunting for it in the console.
|
|
155
|
+
*
|
|
156
|
+
* Accepts any real, path-safe session id — a Claude/Codex UUID *and* a native
|
|
157
|
+
* `ses_…` id from OpenCode or another harness. The console shard uploader
|
|
158
|
+
* (`traces/sync.ts`) syncs sessions with no harness filter, so all of them are
|
|
159
|
+
* addressable; a UUID-only gate would silently drop the link for every non-Claude
|
|
160
|
+
* harness (the whole point of the link). Reject only an id that could not resolve:
|
|
161
|
+
* one with a path separator (URL-unsafe, via {@link isValidMailboxId}) or the bare
|
|
162
|
+
* 8-char footer crumb (a truncated id that would 404).
|
|
163
|
+
*/
|
|
164
|
+
export function sessionConsoleUrl(session) {
|
|
165
|
+
const id = session?.trim();
|
|
166
|
+
if (!id || !isValidMailboxId(id) || /^[0-9a-f]{8}$/i.test(id))
|
|
167
|
+
return undefined;
|
|
168
|
+
return `https://prix.dev/console/sessions/${id}`;
|
|
169
|
+
}
|
|
148
170
|
/**
|
|
149
171
|
* Scrub em/en dashes from outbound phone copy (house rule + iMessage readability).
|
|
150
172
|
* Collapses whitespace; does not invent meaning.
|
|
@@ -158,6 +180,56 @@ export function scrubOutboundDashes(text) {
|
|
|
158
180
|
.replace(/[ \t]{2,}/g, ' ')
|
|
159
181
|
.trim();
|
|
160
182
|
}
|
|
183
|
+
/** Slack mrkdwn labeled link: `<url|label>` renders as blue `label` text. */
|
|
184
|
+
function slackLink(url, label) {
|
|
185
|
+
return `<${url}|${label}>`;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Only Slack renders `<url|label>`, so it is the one format that gets labeled
|
|
189
|
+
* links. iMessage / owner-scoped rush / command / desktop sinks stay `plain`
|
|
190
|
+
* (they can't turn `claude/6fc1db18` blue, and dumping the raw URL is worse than
|
|
191
|
+
* leaving the crumb unlinked — PHNX-3698).
|
|
192
|
+
*
|
|
193
|
+
* The argument is the **resolved provider name**, not the sink's declared
|
|
194
|
+
* channel: an operator can point an arbitrary channel name at the Slack provider
|
|
195
|
+
* through `notify.transports` (e.g. `eng-alerts -> slack`), and delivery keys off
|
|
196
|
+
* that resolved provider (`lookupTransport`), so the format decision must too —
|
|
197
|
+
* otherwise an aliased Slack sink would compose plain while delivering to Slack,
|
|
198
|
+
* or a name remapped AWAY from Slack would emit `<url|label>` markup a non-Slack
|
|
199
|
+
* transport shows literally. {@link resolveSinkProvider} does the mapping.
|
|
200
|
+
*/
|
|
201
|
+
export function sinkMessageFormat(provider) {
|
|
202
|
+
return provider?.trim().toLowerCase() === 'slack' ? 'mrkdwn' : 'plain';
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* The provider a channel name actually delivers through — the same
|
|
206
|
+
* `notify.transports` remap `lookupTransport` applies at delivery — so the format
|
|
207
|
+
* decision and the delivery agree on what Slack is. Identity when no mapping
|
|
208
|
+
* exists (or no `meta`), matching the default name-identity transport rule.
|
|
209
|
+
*/
|
|
210
|
+
function resolveSinkProvider(channel, meta) {
|
|
211
|
+
return meta?.notify?.transports?.[channel] ?? channel;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Replace each real Linear key the text NAMES with a Slack labeled link to its
|
|
215
|
+
* issue — `PHNX-3689` → `<https://linear.app/getrush/issue/PHNX-3689|PHNX-3689>`
|
|
216
|
+
* — so the key itself turns blue in place (no trailing URL line). Plain format,
|
|
217
|
+
* or a key the workspace can't resolve, or a denylisted unit string, is left as
|
|
218
|
+
* the bare key. `linearIssueKeys` is the same canonical detector the trail used,
|
|
219
|
+
* so mrkdwn linkifies exactly the keys plain leaves as text.
|
|
220
|
+
*/
|
|
221
|
+
function linkifyKeys(text, format) {
|
|
222
|
+
if (format !== 'mrkdwn' || !text)
|
|
223
|
+
return text;
|
|
224
|
+
let out = text;
|
|
225
|
+
for (const key of linearIssueKeys(text)) {
|
|
226
|
+
const url = linearIssueUrl(key);
|
|
227
|
+
if (!url)
|
|
228
|
+
continue;
|
|
229
|
+
out = out.replace(new RegExp(`\\b${key}\\b`, 'g'), slackLink(url, key));
|
|
230
|
+
}
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
161
233
|
/**
|
|
162
234
|
* Footer like "Sent from my iPhone" — who posted, a session crumb, which box.
|
|
163
235
|
*
|
|
@@ -165,8 +237,13 @@ export function scrubOutboundDashes(text) {
|
|
|
165
237
|
*
|
|
166
238
|
* Agent name first; session chunk for disambiguation when many groks run;
|
|
167
239
|
* host last. Skip the uninformative default label `agent`.
|
|
240
|
+
*
|
|
241
|
+
* In `mrkdwn` the crumb (`agent/short`) becomes a Slack labeled link to the
|
|
242
|
+
* session's console page, so the human sentence reads identically while the
|
|
243
|
+
* crumb turns blue and taps through (PHNX-3698). `plain` keeps the bare sentence
|
|
244
|
+
* — it can't render a labeled link and must not dump the URL.
|
|
168
245
|
*/
|
|
169
|
-
export function composeBroadcastFooter(ctx) {
|
|
246
|
+
export function composeBroadcastFooter(ctx, format = 'plain') {
|
|
170
247
|
const agent = ctx.agent?.trim();
|
|
171
248
|
const agentLabel = agent && agent !== 'agent' ? agent : undefined;
|
|
172
249
|
const session = shortSessionChunk(ctx.session);
|
|
@@ -178,6 +255,10 @@ export function composeBroadcastFooter(ctx) {
|
|
|
178
255
|
who = agentLabel;
|
|
179
256
|
else if (session)
|
|
180
257
|
who = session;
|
|
258
|
+
// The crumb is only a link when the session resolves to a real console page.
|
|
259
|
+
const consoleUrl = who ? sessionConsoleUrl(ctx.session) : undefined;
|
|
260
|
+
if (who && format === 'mrkdwn' && consoleUrl)
|
|
261
|
+
who = slackLink(consoleUrl, who);
|
|
181
262
|
if (who && host)
|
|
182
263
|
return `Sent from ${who} on ${host}`;
|
|
183
264
|
if (who)
|
|
@@ -236,16 +317,17 @@ export function truncateBroadcastBody(body) {
|
|
|
236
317
|
return body;
|
|
237
318
|
return `${out.trimEnd()}\n… (full in feed)`;
|
|
238
319
|
}
|
|
239
|
-
export function composeBroadcastMessage(ctx) {
|
|
320
|
+
export function composeBroadcastMessage(ctx, format = 'plain') {
|
|
240
321
|
const title = scrubOutboundDashes(ctx.title ?? '');
|
|
241
322
|
const body = truncateBroadcastBody(scrubOutboundDashes(ctx.text ?? ''));
|
|
242
323
|
// Title preferred; if an older post has no title, body alone still sends.
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
324
|
+
// A `TEAM-N` key the human typed is dead text on a phone — in `mrkdwn` the key
|
|
325
|
+
// itself becomes a Slack labeled link in place, so nothing rides a trailing
|
|
326
|
+
// naked URL line; `plain` leaves the bare key (iMessage can't render a label,
|
|
327
|
+
// and dumping the URL is worse than leaving it — PHNX-3698).
|
|
328
|
+
const head = linkifyKeys(title || body, format);
|
|
329
|
+
const mid = title && body && title !== body ? linkifyKeys(body, format) : undefined;
|
|
330
|
+
const footer = composeBroadcastFooter(ctx, format);
|
|
249
331
|
// The action block: the one thing the operator can act on from a phone. Show the
|
|
250
332
|
// choices, then what happens if they do not answer. Deliberately NOT a CLI command
|
|
251
333
|
// (`agents focus <id>` is unusable from a phone) -- the safe default is the real
|
|
@@ -259,8 +341,6 @@ export function composeBroadcastMessage(ctx) {
|
|
|
259
341
|
: `Default: ${scrubOutboundDashes(ctx.safeDefault)}`)
|
|
260
342
|
: undefined;
|
|
261
343
|
const action = [choices, fallback].filter(Boolean).join('\n') || undefined;
|
|
262
|
-
// Link trail after the "Sent from" footer so the human sentence stays at the top.
|
|
263
|
-
const trail = [footer, ...links].filter(Boolean);
|
|
264
344
|
const parts = [];
|
|
265
345
|
if (head)
|
|
266
346
|
parts.push(head);
|
|
@@ -275,16 +355,22 @@ export function composeBroadcastMessage(ctx) {
|
|
|
275
355
|
parts.push('');
|
|
276
356
|
parts.push(action);
|
|
277
357
|
}
|
|
278
|
-
if (
|
|
279
|
-
// Blank line before the footer
|
|
358
|
+
if (footer) {
|
|
359
|
+
// Blank line before the "Sent from" footer (iPhone "Sent from my iPhone" spacing).
|
|
360
|
+
// The crumb/ticket links are inline (footer + prose) — never a trailing URL line.
|
|
280
361
|
if (parts.length)
|
|
281
362
|
parts.push('');
|
|
282
|
-
parts.push(
|
|
363
|
+
parts.push(footer);
|
|
283
364
|
}
|
|
284
365
|
return parts.join('\n').trim();
|
|
285
366
|
}
|
|
286
|
-
/**
|
|
287
|
-
|
|
367
|
+
/**
|
|
368
|
+
* The values a template may reference, resolved once per post. `format` decides
|
|
369
|
+
* how `{message}` surfaces its links — Slack `mrkdwn` (labeled links) vs `plain`
|
|
370
|
+
* (the human sentence, no URLs). The scalar `{ticket_url}`/`{links}` vars are the
|
|
371
|
+
* raw URLs a custom `message:` template can place itself, so they are unaffected.
|
|
372
|
+
*/
|
|
373
|
+
function templateVars(ctx, format = 'plain') {
|
|
288
374
|
return {
|
|
289
375
|
title: ctx.title,
|
|
290
376
|
text: ctx.text,
|
|
@@ -296,7 +382,7 @@ function templateVars(ctx) {
|
|
|
296
382
|
session: ctx.session,
|
|
297
383
|
level: ctx.level,
|
|
298
384
|
links: ctx.links?.length ? ctx.links.join(' ') : undefined,
|
|
299
|
-
message: composeBroadcastMessage(ctx),
|
|
385
|
+
message: composeBroadcastMessage(ctx, format),
|
|
300
386
|
block: ctx.blockId,
|
|
301
387
|
class: ctx.class,
|
|
302
388
|
cost: ctx.cost,
|
|
@@ -330,9 +416,14 @@ export function renderSinkArgv(template, ctx) {
|
|
|
330
416
|
}
|
|
331
417
|
return argv.length > 0 ? argv : undefined;
|
|
332
418
|
}
|
|
333
|
-
/**
|
|
334
|
-
|
|
335
|
-
|
|
419
|
+
/**
|
|
420
|
+
* Render one channel-message template with the same fail-closed placeholder
|
|
421
|
+
* contract as argv. `format` (Slack `mrkdwn` vs `plain`) flows into the shared
|
|
422
|
+
* `{message}` var so a Slack sink gets labeled links and an iMessage/owner sink
|
|
423
|
+
* gets the plain sentence.
|
|
424
|
+
*/
|
|
425
|
+
export function renderSinkMessage(template, ctx, format = 'plain') {
|
|
426
|
+
const vars = templateVars(ctx, format);
|
|
336
427
|
let missing = false;
|
|
337
428
|
const rendered = template.replace(PLACEHOLDER, (whole, key) => {
|
|
338
429
|
const value = vars[key];
|
|
@@ -367,8 +458,12 @@ export function renderSinkMessage(template, ctx) {
|
|
|
367
458
|
* A `channel:` sink is gated by the same `minLevel` rule as a `command:` sink —
|
|
368
459
|
* one level check for both shapes, so a dry-run plan is truthful regardless of
|
|
369
460
|
* which shape an operator's sink uses.
|
|
461
|
+
*
|
|
462
|
+
* `meta` is used only to resolve a channel name to its real provider for the
|
|
463
|
+
* mrkdwn/plain format decision (`notify.transports`), the same map delivery uses;
|
|
464
|
+
* it is optional so a test can plan without a config snapshot (identity mapping).
|
|
370
465
|
*/
|
|
371
|
-
export function planFeedBroadcast(config, ctx) {
|
|
466
|
+
export function planFeedBroadcast(config, ctx, meta) {
|
|
372
467
|
if (!config)
|
|
373
468
|
return [];
|
|
374
469
|
const planned = [];
|
|
@@ -386,7 +481,14 @@ export function planFeedBroadcast(config, ctx) {
|
|
|
386
481
|
// placeholder below).
|
|
387
482
|
if (!isOwnerAlias(channel) && !sink.to?.trim())
|
|
388
483
|
continue;
|
|
389
|
-
|
|
484
|
+
// Slack renders labeled links; every other channel (owner alias, iMessage,
|
|
485
|
+
// telegram, discord, mailbox, desktop) stays plain (PHNX-3698). The owner
|
|
486
|
+
// alias is deliberately plain even when it fans out to a Slack destination:
|
|
487
|
+
// it delivers ONE shared string to every channel in owner.policy.normal, so
|
|
488
|
+
// mrkdwn markup would corrupt a sibling iMessage copy. Keying on the
|
|
489
|
+
// resolved provider (not the raw name) matches what delivery does.
|
|
490
|
+
const provider = isOwnerAlias(channel) ? channel : resolveSinkProvider(channel, meta);
|
|
491
|
+
const text = renderSinkMessage(sink.message ?? '{message}', ctx, sinkMessageFormat(provider));
|
|
390
492
|
if (!text)
|
|
391
493
|
continue;
|
|
392
494
|
planned.push({
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export declare const FLEET_SHARED_REPO_SYNC_DEADLINE_MS = 45000;
|
|
2
|
+
export declare const FLEET_SHARED_REPO_KILL_GRACE_MS = 250;
|
|
3
|
+
export interface BoundedProcessResult {
|
|
4
|
+
code: number | null;
|
|
5
|
+
stdout: string;
|
|
6
|
+
stderr: string;
|
|
7
|
+
timedOut: boolean;
|
|
8
|
+
}
|
|
9
|
+
export interface FleetSharedRepoSyncOptions {
|
|
10
|
+
userAgentsDir?: string;
|
|
11
|
+
device?: string;
|
|
12
|
+
timeoutMs?: number;
|
|
13
|
+
/** Test-only redirection for the untracked cross-process lock target. */
|
|
14
|
+
lockPath?: string;
|
|
15
|
+
}
|
|
16
|
+
export interface FleetSharedRepoSyncResult {
|
|
17
|
+
success: boolean;
|
|
18
|
+
committed: boolean;
|
|
19
|
+
pushed: boolean;
|
|
20
|
+
commit: string | null;
|
|
21
|
+
timedOut: boolean;
|
|
22
|
+
skipped: string | null;
|
|
23
|
+
error: string | null;
|
|
24
|
+
}
|
|
25
|
+
/** Run one real process asynchronously with a hard wall-clock/process-tree bound. */
|
|
26
|
+
export declare function runBoundedProcess(command: string, args: string[], options: {
|
|
27
|
+
cwd: string;
|
|
28
|
+
timeoutMs: number;
|
|
29
|
+
env?: NodeJS.ProcessEnv;
|
|
30
|
+
}): Promise<BoundedProcessResult>;
|
|
31
|
+
/**
|
|
32
|
+
* Commit/pull/push this device's shared state under one cross-process lock.
|
|
33
|
+
* Callers publish their local fields first and consume peer fields only after
|
|
34
|
+
* this resolves successfully.
|
|
35
|
+
*/
|
|
36
|
+
export declare function syncFleetSharedStateRepo(options?: FleetSharedRepoSyncOptions): Promise<FleetSharedRepoSyncResult>;
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Automatic transport for the non-secret fleet state stored in the user repo.
|
|
3
|
+
*
|
|
4
|
+
* Usage/auth services publish only their own conflict-free
|
|
5
|
+
* `devices/<device>/daemon-state.json` file. This module commits that one path,
|
|
6
|
+
* rebases the user repo, and pushes it so peer daemons can read the snapshot
|
|
7
|
+
* after their own fetch/rebase. Git runs asynchronously under one cross-process
|
|
8
|
+
* lock; every invocation has a wall-clock deadline and a timed-out process tree
|
|
9
|
+
* is terminated, including git's ssh child and its remote channel.
|
|
10
|
+
*/
|
|
11
|
+
import { spawn } from 'node:child_process';
|
|
12
|
+
import * as fs from 'node:fs';
|
|
13
|
+
import * as path from 'node:path';
|
|
14
|
+
import lockfile from 'proper-lockfile';
|
|
15
|
+
import { fleetSharedStatePath } from './fleet-shared-state.js';
|
|
16
|
+
import { ensureLockTarget } from './fs-atomic.js';
|
|
17
|
+
import { logAndContinueOnLockCompromised } from './lock-compromise.js';
|
|
18
|
+
import { machineId } from './session/sync/config.js';
|
|
19
|
+
import { getDaemonDir, getUserAgentsDir } from './state.js';
|
|
20
|
+
export const FLEET_SHARED_REPO_SYNC_DEADLINE_MS = 45_000;
|
|
21
|
+
export const FLEET_SHARED_REPO_KILL_GRACE_MS = 250;
|
|
22
|
+
const FLEET_SHARED_REPO_OUTPUT_MAX_BYTES = 1024 * 1024;
|
|
23
|
+
const FLEET_SHARED_REPO_PUSH_ATTEMPTS = 3;
|
|
24
|
+
const FLEET_SHARED_REPO_REBASE_CLEANUP_RESERVE_MS = 5_000;
|
|
25
|
+
function signalProcessTree(child, signal) {
|
|
26
|
+
const pid = child.pid;
|
|
27
|
+
if (!pid)
|
|
28
|
+
return;
|
|
29
|
+
if (process.platform === 'win32') {
|
|
30
|
+
// Windows has no process-group signal. taskkill is asynchronous here so a
|
|
31
|
+
// timeout path cannot replace one event-loop stall with another.
|
|
32
|
+
const killer = spawn('taskkill', ['/F', '/T', '/PID', String(pid)], {
|
|
33
|
+
stdio: 'ignore',
|
|
34
|
+
windowsHide: true,
|
|
35
|
+
});
|
|
36
|
+
killer.unref();
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
// The child is spawned detached on POSIX, so -pid reaches git plus ssh.
|
|
41
|
+
process.kill(-pid, signal);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
try {
|
|
45
|
+
child.kill(signal);
|
|
46
|
+
}
|
|
47
|
+
catch { /* already gone */ }
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** Run one real process asynchronously with a hard wall-clock/process-tree bound. */
|
|
51
|
+
export function runBoundedProcess(command, args, options) {
|
|
52
|
+
return new Promise((resolve) => {
|
|
53
|
+
const child = spawn(command, args, {
|
|
54
|
+
cwd: options.cwd,
|
|
55
|
+
env: options.env ?? process.env,
|
|
56
|
+
detached: process.platform !== 'win32',
|
|
57
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
58
|
+
windowsHide: true,
|
|
59
|
+
});
|
|
60
|
+
let stdout = '';
|
|
61
|
+
let stderr = '';
|
|
62
|
+
let settled = false;
|
|
63
|
+
let timedOut = false;
|
|
64
|
+
let overflow = false;
|
|
65
|
+
let killTimer = null;
|
|
66
|
+
const finish = (code) => {
|
|
67
|
+
if (settled)
|
|
68
|
+
return;
|
|
69
|
+
settled = true;
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
if (killTimer)
|
|
72
|
+
clearTimeout(killTimer);
|
|
73
|
+
resolve({ code, stdout, stderr, timedOut });
|
|
74
|
+
};
|
|
75
|
+
const hardKill = () => {
|
|
76
|
+
signalProcessTree(child, 'SIGKILL');
|
|
77
|
+
if (process.platform === 'win32' && !killTimer) {
|
|
78
|
+
// taskkill owns the descendant cleanup; this fallback only guarantees
|
|
79
|
+
// the direct child's Promise settles if taskkill itself cannot run.
|
|
80
|
+
killTimer = setTimeout(() => {
|
|
81
|
+
if (!settled) {
|
|
82
|
+
try {
|
|
83
|
+
child.kill('SIGKILL');
|
|
84
|
+
}
|
|
85
|
+
catch { /* already gone */ }
|
|
86
|
+
}
|
|
87
|
+
}, FLEET_SHARED_REPO_KILL_GRACE_MS);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
const timer = setTimeout(() => {
|
|
91
|
+
timedOut = true;
|
|
92
|
+
if (process.platform === 'win32') {
|
|
93
|
+
hardKill();
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
signalProcessTree(child, 'SIGTERM');
|
|
97
|
+
killTimer = setTimeout(() => {
|
|
98
|
+
if (!settled)
|
|
99
|
+
hardKill();
|
|
100
|
+
}, FLEET_SHARED_REPO_KILL_GRACE_MS);
|
|
101
|
+
}, Math.max(1, options.timeoutMs));
|
|
102
|
+
const append = (stream, chunk) => {
|
|
103
|
+
if (overflow)
|
|
104
|
+
return;
|
|
105
|
+
const value = String(chunk);
|
|
106
|
+
if (Buffer.byteLength(stdout) + Buffer.byteLength(stderr) + Buffer.byteLength(value) > FLEET_SHARED_REPO_OUTPUT_MAX_BYTES) {
|
|
107
|
+
overflow = true;
|
|
108
|
+
stderr += '\ngit output exceeded 1 MiB';
|
|
109
|
+
hardKill();
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (stream === 'stdout')
|
|
113
|
+
stdout += value;
|
|
114
|
+
else
|
|
115
|
+
stderr += value;
|
|
116
|
+
};
|
|
117
|
+
child.stdout?.on('data', (chunk) => append('stdout', chunk));
|
|
118
|
+
child.stderr?.on('data', (chunk) => append('stderr', chunk));
|
|
119
|
+
child.on('error', (err) => {
|
|
120
|
+
stderr += err.message;
|
|
121
|
+
finish(null);
|
|
122
|
+
});
|
|
123
|
+
child.on('close', (code) => finish(code));
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
function failure(command, result) {
|
|
127
|
+
const detail = result.timedOut
|
|
128
|
+
? `${command} timed out`
|
|
129
|
+
: result.stderr.trim() || result.stdout.trim() || `${command} exited ${result.code ?? 'without a status'}`;
|
|
130
|
+
return {
|
|
131
|
+
success: false,
|
|
132
|
+
committed: false,
|
|
133
|
+
pushed: false,
|
|
134
|
+
commit: null,
|
|
135
|
+
timedOut: result.timedOut,
|
|
136
|
+
skipped: null,
|
|
137
|
+
error: detail,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
function isPushRace(result) {
|
|
141
|
+
const text = `${result.stdout}\n${result.stderr}`.toLowerCase();
|
|
142
|
+
return text.includes('non-fast-forward') || text.includes('fetch first') || text.includes('[rejected]');
|
|
143
|
+
}
|
|
144
|
+
function createdAutostashObject(result) {
|
|
145
|
+
return `${result.stdout}\n${result.stderr}`.match(/created autostash:\s*([0-9a-f]+)/i)?.[1] ?? null;
|
|
146
|
+
}
|
|
147
|
+
function retainedAutostash(stashList, objectId) {
|
|
148
|
+
if (stashList.code === 0) {
|
|
149
|
+
const entries = stashList.stdout.trim().split('\n').filter(Boolean).map((line) => {
|
|
150
|
+
const [ref = '', hash = '', ...subject] = line.split('\t');
|
|
151
|
+
return { ref, hash, subject: subject.join('\t') };
|
|
152
|
+
});
|
|
153
|
+
const matched = objectId
|
|
154
|
+
? entries.find((entry) => entry.hash.startsWith(objectId) || objectId.startsWith(entry.hash))
|
|
155
|
+
: entries.find((entry) => /autostash/i.test(entry.subject));
|
|
156
|
+
if (matched?.ref)
|
|
157
|
+
return { label: `${matched.ref} (${matched.hash.slice(0, 8)})`, ref: matched.ref };
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
label: objectId ? `autostash object ${objectId}` : 'the retained autostash',
|
|
161
|
+
ref: null,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
async function performFleetSharedRepoSync(root, device, timeoutMs) {
|
|
165
|
+
if (!fs.existsSync(path.join(root, '.git'))) {
|
|
166
|
+
return {
|
|
167
|
+
success: false,
|
|
168
|
+
committed: false,
|
|
169
|
+
pushed: false,
|
|
170
|
+
commit: null,
|
|
171
|
+
timedOut: false,
|
|
172
|
+
skipped: `user store is not git-backed: ${root}`,
|
|
173
|
+
error: null,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
const deadline = Date.now() + timeoutMs;
|
|
177
|
+
const git = async (args, reserveMs = 0) => {
|
|
178
|
+
const remaining = deadline - Date.now() - reserveMs;
|
|
179
|
+
if (remaining <= 0)
|
|
180
|
+
return { code: null, stdout: '', stderr: '', timedOut: true };
|
|
181
|
+
return runBoundedProcess('git', args, {
|
|
182
|
+
cwd: root,
|
|
183
|
+
timeoutMs: remaining,
|
|
184
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
185
|
+
});
|
|
186
|
+
};
|
|
187
|
+
const branchResult = await git(['branch', '--show-current']);
|
|
188
|
+
if (branchResult.code !== 0)
|
|
189
|
+
return failure('git branch --show-current', branchResult);
|
|
190
|
+
const branch = branchResult.stdout.trim();
|
|
191
|
+
if (!branch) {
|
|
192
|
+
return {
|
|
193
|
+
success: false,
|
|
194
|
+
committed: false,
|
|
195
|
+
pushed: false,
|
|
196
|
+
commit: null,
|
|
197
|
+
timedOut: false,
|
|
198
|
+
skipped: null,
|
|
199
|
+
error: 'user store is on a detached HEAD',
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
const ownedFile = fleetSharedStatePath(device, root);
|
|
203
|
+
const relativeOwnedFile = path.relative(root, ownedFile).split(path.sep).join('/');
|
|
204
|
+
let committed = false;
|
|
205
|
+
if (fs.existsSync(ownedFile)) {
|
|
206
|
+
const status = await git(['status', '--porcelain=v1', '--', relativeOwnedFile]);
|
|
207
|
+
if (status.code !== 0)
|
|
208
|
+
return failure('git status', status);
|
|
209
|
+
if (status.stdout.trim()) {
|
|
210
|
+
const add = await git(['add', '--', relativeOwnedFile]);
|
|
211
|
+
if (add.code !== 0)
|
|
212
|
+
return failure('git add', add);
|
|
213
|
+
const commit = await git([
|
|
214
|
+
'-c', 'commit.gpgsign=false',
|
|
215
|
+
'commit', '--no-verify', '-m', `chore(devices): publish ${device} daemon state`,
|
|
216
|
+
'--', relativeOwnedFile,
|
|
217
|
+
]);
|
|
218
|
+
if (commit.code !== 0)
|
|
219
|
+
return failure('git commit', commit);
|
|
220
|
+
committed = true;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
for (let attempt = 1; attempt <= FLEET_SHARED_REPO_PUSH_ATTEMPTS; attempt++) {
|
|
224
|
+
const fetch = await git(['fetch', 'origin']);
|
|
225
|
+
if (fetch.code !== 0)
|
|
226
|
+
return { ...failure('git fetch', fetch), committed };
|
|
227
|
+
// Keep enough of the same wall-clock bound available to remove a botched
|
|
228
|
+
// autostash pop from the operator's live checkout before returning.
|
|
229
|
+
const rebase = await git(['rebase', '--autostash', `origin/${branch}`], FLEET_SHARED_REPO_REBASE_CLEANUP_RESERVE_MS);
|
|
230
|
+
if (rebase.code !== 0) {
|
|
231
|
+
// Rebase can leave control files behind even when the child was killed.
|
|
232
|
+
// Abort is bounded by the same overall deadline and never hides the cause.
|
|
233
|
+
await git(['rebase', '--abort']);
|
|
234
|
+
return { ...failure('git rebase', rebase), committed };
|
|
235
|
+
}
|
|
236
|
+
// `git rebase --autostash` exits zero when the rebase succeeds but applying
|
|
237
|
+
// the autostash conflicts. Never push or leave those conflict markers in
|
|
238
|
+
// the operator's real ~/.agents checkout. Git retains the original changes
|
|
239
|
+
// in the stash on this path, so reset only the failed pop to rebased HEAD.
|
|
240
|
+
const unmerged = await git(['ls-files', '--unmerged']);
|
|
241
|
+
if (unmerged.code !== 0)
|
|
242
|
+
return { ...failure('git ls-files --unmerged', unmerged), committed };
|
|
243
|
+
if (unmerged.stdout.trim()) {
|
|
244
|
+
const autostashObject = createdAutostashObject(rebase);
|
|
245
|
+
const reset = await git(['reset', '--hard', 'HEAD']);
|
|
246
|
+
const cleaned = await git(['ls-files', '--unmerged']);
|
|
247
|
+
const stashList = await git(['stash', 'list', '--format=%gd%x09%H%x09%gs']);
|
|
248
|
+
const stash = retainedAutostash(stashList, autostashObject);
|
|
249
|
+
if (reset.code !== 0 || cleaned.code !== 0 || cleaned.stdout.trim()) {
|
|
250
|
+
const cleanup = reset.code !== 0
|
|
251
|
+
? failure('git reset --hard HEAD', reset).error
|
|
252
|
+
: cleaned.code !== 0
|
|
253
|
+
? failure('git ls-files --unmerged', cleaned).error
|
|
254
|
+
: 'unmerged index entries remain after git reset --hard HEAD';
|
|
255
|
+
return {
|
|
256
|
+
success: false,
|
|
257
|
+
committed,
|
|
258
|
+
pushed: false,
|
|
259
|
+
commit: null,
|
|
260
|
+
timedOut: reset.timedOut || cleaned.timedOut,
|
|
261
|
+
skipped: null,
|
|
262
|
+
error: `autostash pop conflicted; push refused, but cleanup failed: ${cleanup}. Original tracked changes remain safe in ${stash.label}`,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
success: false,
|
|
267
|
+
committed,
|
|
268
|
+
pushed: false,
|
|
269
|
+
commit: null,
|
|
270
|
+
timedOut: false,
|
|
271
|
+
skipped: null,
|
|
272
|
+
error: `autostash pop conflicted; push refused and the worktree was reset to rebased HEAD without conflict markers. Original tracked changes remain safe in ${stash.label}; inspect with ${stash.ref ? `git stash show -p ${stash.ref}` : 'git stash list'}`,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
const push = await git(['push', '--', 'origin', branch]);
|
|
276
|
+
if (push.code === 0) {
|
|
277
|
+
const head = await git(['rev-parse', '--short=8', 'HEAD']);
|
|
278
|
+
return {
|
|
279
|
+
success: true,
|
|
280
|
+
committed,
|
|
281
|
+
pushed: committed || push.stdout.trim().length > 0 || push.stderr.includes('->'),
|
|
282
|
+
commit: head.code === 0 ? head.stdout.trim() : null,
|
|
283
|
+
timedOut: false,
|
|
284
|
+
skipped: null,
|
|
285
|
+
error: null,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
if (attempt === FLEET_SHARED_REPO_PUSH_ATTEMPTS || !isPushRace(push)) {
|
|
289
|
+
return { ...failure('git push', push), committed };
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
throw new Error('unreachable fleet shared repo push loop');
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Commit/pull/push this device's shared state under one cross-process lock.
|
|
296
|
+
* Callers publish their local fields first and consume peer fields only after
|
|
297
|
+
* this resolves successfully.
|
|
298
|
+
*/
|
|
299
|
+
export async function syncFleetSharedStateRepo(options = {}) {
|
|
300
|
+
const root = options.userAgentsDir ?? getUserAgentsDir();
|
|
301
|
+
const device = options.device ?? machineId();
|
|
302
|
+
const timeoutMs = options.timeoutMs ?? FLEET_SHARED_REPO_SYNC_DEADLINE_MS;
|
|
303
|
+
const lockPath = options.lockPath ?? path.join(getDaemonDir(), 'fleet-shared-repo-sync');
|
|
304
|
+
ensureLockTarget(lockPath, '');
|
|
305
|
+
let release = null;
|
|
306
|
+
try {
|
|
307
|
+
release = await lockfile.lock(lockPath, {
|
|
308
|
+
stale: Math.max(10_000, timeoutMs + 5_000),
|
|
309
|
+
retries: { retries: 20, factor: 1, minTimeout: 100, maxTimeout: 100 },
|
|
310
|
+
onCompromised: logAndContinueOnLockCompromised('fleet shared repo sync'),
|
|
311
|
+
});
|
|
312
|
+
return await performFleetSharedRepoSync(root, device, timeoutMs);
|
|
313
|
+
}
|
|
314
|
+
catch (err) {
|
|
315
|
+
return {
|
|
316
|
+
success: false,
|
|
317
|
+
committed: false,
|
|
318
|
+
pushed: false,
|
|
319
|
+
commit: null,
|
|
320
|
+
timedOut: false,
|
|
321
|
+
skipped: null,
|
|
322
|
+
error: err.message,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
finally {
|
|
326
|
+
if (release) {
|
|
327
|
+
try {
|
|
328
|
+
await release();
|
|
329
|
+
}
|
|
330
|
+
catch { /* compromised/already released */ }
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { CachedUsageSnapshot } from './accounting/usage.js';
|
|
2
|
+
export declare const FLEET_SHARED_STATE_VERSION = 1;
|
|
3
|
+
export declare const FLEET_SHARED_STATE_FILE = "daemon-state.json";
|
|
4
|
+
export type SharedAuthStatus = 'ready' | 'missing' | 'invalid';
|
|
5
|
+
export interface FleetSharedDeviceState {
|
|
6
|
+
version: typeof FLEET_SHARED_STATE_VERSION;
|
|
7
|
+
device: string;
|
|
8
|
+
usage?: {
|
|
9
|
+
rows: Record<string, CachedUsageSnapshot>;
|
|
10
|
+
};
|
|
11
|
+
auth?: {
|
|
12
|
+
status: SharedAuthStatus;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export interface FleetSharedStatePatch {
|
|
16
|
+
usage?: FleetSharedDeviceState['usage'];
|
|
17
|
+
auth?: FleetSharedDeviceState['auth'];
|
|
18
|
+
}
|
|
19
|
+
export interface FleetSharedStateReadResult {
|
|
20
|
+
states: FleetSharedDeviceState[];
|
|
21
|
+
errors: Array<{
|
|
22
|
+
device: string;
|
|
23
|
+
message: string;
|
|
24
|
+
}>;
|
|
25
|
+
}
|
|
26
|
+
/** Path owned by one device in the conflict-free tracked device-doc tree. */
|
|
27
|
+
export declare function fleetSharedStatePath(device: string, userAgentsDir?: string): string;
|
|
28
|
+
/**
|
|
29
|
+
* Merge one daemon-owned field into this device's shared file under a real
|
|
30
|
+
* inter-process lock. Stable serialization avoids dirtying the user repo when
|
|
31
|
+
* neither usage nor auth state changed.
|
|
32
|
+
*/
|
|
33
|
+
export declare function updateFleetSharedDeviceState(device: string, patch: FleetSharedStatePatch, userAgentsDir?: string): {
|
|
34
|
+
changed: boolean;
|
|
35
|
+
path: string;
|
|
36
|
+
};
|
|
37
|
+
/** Read every valid peer-owned state file; malformed peers fail separately. */
|
|
38
|
+
export declare function readFleetSharedDeviceStates(userAgentsDir?: string): FleetSharedStateReadResult;
|