@phnx-labs/agents-cli 1.22.51 → 1.22.53
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 +238 -0
- package/README.md +1 -1
- package/dist/commands/accounts.js +1 -1
- package/dist/commands/attach.js +7 -0
- package/dist/commands/browser.js +118 -56
- package/dist/commands/daemon.d.ts +2 -0
- package/dist/commands/daemon.js +8 -4
- package/dist/commands/detach.js +1 -1
- package/dist/commands/exec.js +16 -9
- package/dist/commands/fleet-capture.js +7 -0
- package/dist/commands/focus.d.ts +1 -10
- package/dist/commands/focus.js +16 -79
- package/dist/commands/go.d.ts +26 -0
- package/dist/commands/go.js +65 -6
- package/dist/commands/monitors.js +1 -1
- package/dist/commands/repo.js +31 -3
- package/dist/commands/sessions-inject.js +8 -3
- package/dist/commands/sessions-picker.js +2 -1
- package/dist/commands/sessions-resume.d.ts +1 -0
- package/dist/commands/sessions-resume.js +13 -2
- package/dist/commands/sessions-stop.js +1 -1
- package/dist/commands/sessions.d.ts +23 -13
- package/dist/commands/sessions.js +69 -39
- package/dist/commands/setup-browser.d.ts +5 -2
- package/dist/commands/setup-browser.js +14 -29
- package/dist/commands/setup-preferences.d.ts +22 -3
- package/dist/commands/setup-preferences.js +25 -8
- package/dist/commands/share.js +12 -8
- package/dist/commands/ssh.js +35 -12
- package/dist/commands/status.js +5 -0
- package/dist/commands/sync.js +102 -2
- package/dist/commands/tmux.d.ts +8 -1
- package/dist/commands/tmux.js +167 -17
- package/dist/lib/account-registry.d.ts +15 -5
- package/dist/lib/account-registry.js +150 -50
- package/dist/lib/answer-router.js +2 -1
- package/dist/lib/browser/ipc.d.ts +44 -0
- package/dist/lib/browser/ipc.js +120 -8
- package/dist/lib/browser/profiles.d.ts +57 -17
- package/dist/lib/browser/profiles.js +77 -53
- package/dist/lib/browser/registry.d.ts +44 -14
- package/dist/lib/browser/registry.js +141 -45
- package/dist/lib/browser/runtime-state.d.ts +4 -2
- package/dist/lib/browser/runtime-state.js +4 -2
- package/dist/lib/browser/service.js +4 -3
- package/dist/lib/channels/owner-forward.d.ts +88 -0
- package/dist/lib/channels/owner-forward.js +116 -0
- package/dist/lib/channels/owner-sink.js +7 -0
- package/dist/lib/daemon/runner.js +10 -2
- package/dist/lib/device-config.js +3 -2
- package/dist/lib/devices/config-migration.js +147 -1
- package/dist/lib/devices/device-docs.d.ts +35 -0
- package/dist/lib/devices/device-docs.js +163 -0
- package/dist/lib/devices/discovery-policy.d.ts +14 -2
- package/dist/lib/devices/discovery-policy.js +31 -21
- package/dist/lib/devices/registry.d.ts +11 -5
- package/dist/lib/devices/registry.js +46 -18
- package/dist/lib/exec.d.ts +66 -28
- package/dist/lib/exec.js +71 -26
- package/dist/lib/feed/feed.d.ts +10 -2
- package/dist/lib/feed/feed.js +12 -1
- package/dist/lib/feed-broadcast.js +15 -1
- package/dist/lib/git.d.ts +93 -0
- package/dist/lib/git.js +232 -0
- package/dist/lib/hosts/dispatch.d.ts +4 -3
- package/dist/lib/hosts/dispatch.js +12 -8
- package/dist/lib/hosts/providers/local.d.ts +9 -3
- package/dist/lib/hosts/providers/local.js +23 -12
- package/dist/lib/hosts/reconnect.d.ts +7 -4
- package/dist/lib/hosts/reconnect.js +29 -25
- package/dist/lib/hosts/registry.js +4 -1
- package/dist/lib/hosts/remote-os.js +3 -1
- package/dist/lib/monitors/remote.d.ts +18 -1
- package/dist/lib/monitors/remote.js +15 -2
- package/dist/lib/notify.d.ts +7 -0
- package/dist/lib/notify.js +15 -1
- package/dist/lib/session/active.d.ts +10 -1
- package/dist/lib/session/active.js +7 -1
- package/dist/lib/session/actor-sidecar.d.ts +7 -0
- package/dist/lib/session/actor-sidecar.js +2 -0
- package/dist/lib/session/db.d.ts +1 -1
- package/dist/lib/session/db.js +39 -3
- package/dist/lib/session/discover.js +7 -12
- package/dist/lib/session/live-metadata.js +1 -0
- package/dist/lib/session/local-tmux-attach.d.ts +69 -0
- package/dist/lib/session/local-tmux-attach.js +164 -0
- package/dist/lib/session/pid-registry.d.ts +7 -0
- package/dist/lib/session/prompt.d.ts +15 -0
- package/dist/lib/session/prompt.js +21 -0
- package/dist/lib/session/remote-active.d.ts +8 -0
- package/dist/lib/session/remote-active.js +1 -0
- package/dist/lib/session/types.d.ts +17 -0
- package/dist/lib/session/types.js +10 -0
- package/dist/lib/share/publish.d.ts +8 -11
- package/dist/lib/share/publish.js +16 -20
- package/dist/lib/share/worker-template.js +104 -12
- package/dist/lib/state.d.ts +8 -0
- package/dist/lib/state.js +143 -11
- package/dist/lib/sync-status.d.ts +17 -0
- package/dist/lib/sync-status.js +21 -2
- package/dist/lib/terminal/resolve.d.ts +7 -0
- package/dist/lib/terminal/resolve.js +41 -2
- package/dist/lib/tmux/index.d.ts +1 -1
- package/dist/lib/tmux/index.js +1 -1
- package/dist/lib/tmux/session.d.ts +10 -0
- package/dist/lib/tmux/session.js +29 -0
- package/dist/lib/traces/insights.d.ts +67 -0
- package/dist/lib/traces/insights.js +178 -0
- package/dist/lib/traces/phenotype.d.ts +67 -0
- package/dist/lib/traces/phenotype.js +437 -0
- package/dist/lib/traces/segments.d.ts +133 -0
- package/dist/lib/traces/segments.js +301 -0
- package/dist/lib/traces/sync.d.ts +33 -0
- package/dist/lib/traces/sync.js +11 -2
- package/dist/lib/types.d.ts +47 -1
- package/dist/lib/watchdog/runner.js +18 -4
- package/package.json +1 -1
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
export interface PidSessionEntry {
|
|
2
2
|
pid: number;
|
|
3
3
|
agent: string;
|
|
4
|
+
/**
|
|
5
|
+
* Custom harness / profile name when this pid was launched via
|
|
6
|
+
* `agents run <profile>` (e.g. `deepseek`). `agent` stays the HOST
|
|
7
|
+
* CLI (`claude`) so process matching and transcript discovery keep
|
|
8
|
+
* working. `sessions --active` displays this when set (PHNX-2935).
|
|
9
|
+
*/
|
|
10
|
+
harness?: string;
|
|
4
11
|
/** The launch session id. Present for agents launched with a known id (Claude). */
|
|
5
12
|
sessionId?: string;
|
|
6
13
|
cwd?: string;
|
|
@@ -69,5 +69,20 @@ export interface ClassifiedPrompt {
|
|
|
69
69
|
export declare function classifyUserPrompt(raw: string | undefined, opts?: {
|
|
70
70
|
hasImageAttachment?: boolean;
|
|
71
71
|
}): ClassifiedPrompt;
|
|
72
|
+
/**
|
|
73
|
+
* Collapse a harness-generated session title when it is injected skill
|
|
74
|
+
* scaffolding; otherwise leave the title as the harness wrote it.
|
|
75
|
+
*
|
|
76
|
+
* Claude's `ai-title` and Cursor's `chatMeta.title` are both derived from the
|
|
77
|
+
* first turn, so a session opened with a skill gets named after the injected
|
|
78
|
+
* "Base directory for this skill: …" line. {@link classifyUserPrompt} reports
|
|
79
|
+
* `kind: 'skill'` only for that line, so collapse it to `/<skill>`. Empty or
|
|
80
|
+
* whitespace-only input yields `undefined` so the caller falls through to the
|
|
81
|
+
* first-prompt topic.
|
|
82
|
+
*
|
|
83
|
+
* A user-authored title (Claude `/rename` / `custom-title`) is never passed
|
|
84
|
+
* here — the caller keeps it verbatim.
|
|
85
|
+
*/
|
|
86
|
+
export declare function cleanGeneratedSessionLabel(title: string | undefined): string | undefined;
|
|
72
87
|
/** Extract a one-line topic from a raw user message, or undefined if the message is pure noise. */
|
|
73
88
|
export declare function extractSessionTopic(raw: string): string | undefined;
|
|
@@ -201,6 +201,27 @@ export function classifyUserPrompt(raw, opts = {}) {
|
|
|
201
201
|
const display = (cleanedFirst || firstLine).replace(IMAGE_PATH_RE, '[image]').trim();
|
|
202
202
|
return { clean: display, kind: 'text' };
|
|
203
203
|
}
|
|
204
|
+
/**
|
|
205
|
+
* Collapse a harness-generated session title when it is injected skill
|
|
206
|
+
* scaffolding; otherwise leave the title as the harness wrote it.
|
|
207
|
+
*
|
|
208
|
+
* Claude's `ai-title` and Cursor's `chatMeta.title` are both derived from the
|
|
209
|
+
* first turn, so a session opened with a skill gets named after the injected
|
|
210
|
+
* "Base directory for this skill: …" line. {@link classifyUserPrompt} reports
|
|
211
|
+
* `kind: 'skill'` only for that line, so collapse it to `/<skill>`. Empty or
|
|
212
|
+
* whitespace-only input yields `undefined` so the caller falls through to the
|
|
213
|
+
* first-prompt topic.
|
|
214
|
+
*
|
|
215
|
+
* A user-authored title (Claude `/rename` / `custom-title`) is never passed
|
|
216
|
+
* here — the caller keeps it verbatim.
|
|
217
|
+
*/
|
|
218
|
+
export function cleanGeneratedSessionLabel(title) {
|
|
219
|
+
const trimmed = title?.trim();
|
|
220
|
+
if (!trimmed)
|
|
221
|
+
return undefined;
|
|
222
|
+
const classified = classifyUserPrompt(trimmed);
|
|
223
|
+
return classified.kind === 'skill' ? classified.clean : trimmed;
|
|
224
|
+
}
|
|
204
225
|
/** Extract a one-line topic from a raw user message, or undefined if the message is pure noise. */
|
|
205
226
|
export function extractSessionTopic(raw) {
|
|
206
227
|
if (!raw.trim())
|
|
@@ -39,4 +39,12 @@ export interface RemoteActiveResult {
|
|
|
39
39
|
*/
|
|
40
40
|
export declare function gatherRemoteActive(hosts?: string[], opts?: {
|
|
41
41
|
quiet?: boolean;
|
|
42
|
+
/**
|
|
43
|
+
* Opt-in first-hit abort. Omitted by default so `--active`, projects, and
|
|
44
|
+
* `focus` with no id keep all-settle. Detach/stop pass this for a unique
|
|
45
|
+
* live id so a reachable hit does not wait out sleeping peers.
|
|
46
|
+
*/
|
|
47
|
+
earlyExit?: {
|
|
48
|
+
isDefinitive: (item: ActiveSession, machine: string) => boolean;
|
|
49
|
+
};
|
|
42
50
|
}): Promise<RemoteActiveResult>;
|
|
@@ -31,6 +31,16 @@ export declare const AG_TMUX_NAME_RE: RegExp;
|
|
|
31
31
|
/** Whether `name` is an `ag-<agent>-<shortid>` tmux session name. */
|
|
32
32
|
export declare function isAgentTmuxAlias(name: string): boolean;
|
|
33
33
|
export declare function isSessionTrackedAgent(agent: string): agent is SessionAgentId;
|
|
34
|
+
/**
|
|
35
|
+
* The name `agents sessions` shows for a run. A custom harness launched via a
|
|
36
|
+
* profile (`agents run deepseek`) keeps its host agent for transcript discovery
|
|
37
|
+
* and parsing (`claude`) and stamps the profile name on `harness`. Display
|
|
38
|
+
* prefers that stamp so a deepseek run is not listed as claude (PHNX-2935).
|
|
39
|
+
*/
|
|
40
|
+
export declare function sessionDisplayAgent(session: {
|
|
41
|
+
agent: string;
|
|
42
|
+
harness?: string | null;
|
|
43
|
+
}): string;
|
|
34
44
|
/** A single normalized event within a session (message, tool call, thinking, etc.). */
|
|
35
45
|
export interface SessionEvent {
|
|
36
46
|
type: 'message' | 'tool_use' | 'tool_result' | 'thinking' | 'error' | 'init' | 'result' | 'usage' | 'attachment' | 'hook' | 'interrupt';
|
|
@@ -154,6 +164,13 @@ export interface SessionMeta {
|
|
|
154
164
|
id: string;
|
|
155
165
|
shortId: string;
|
|
156
166
|
agent: SessionAgentId;
|
|
167
|
+
/**
|
|
168
|
+
* Custom harness / profile name when this session was launched via
|
|
169
|
+
* `agents run <profile>` (e.g. `deepseek`). `agent` stays the HOST
|
|
170
|
+
* harness that produced the transcript (`claude`, …) so discovery and
|
|
171
|
+
* parsing keep working. Display surfaces this when set (PHNX-2935).
|
|
172
|
+
*/
|
|
173
|
+
harness?: string;
|
|
157
174
|
/** Where the indexed transcript came from. Routine rows are archived from a run directory. */
|
|
158
175
|
origin?: 'cli' | 'routine';
|
|
159
176
|
/** Routine name for transcripts archived from ~/.agents/.history/runs/<name>/<runId>/. */
|
|
@@ -31,3 +31,13 @@ export function isAgentTmuxAlias(name) {
|
|
|
31
31
|
export function isSessionTrackedAgent(agent) {
|
|
32
32
|
return SESSION_AGENTS.includes(agent);
|
|
33
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* The name `agents sessions` shows for a run. A custom harness launched via a
|
|
36
|
+
* profile (`agents run deepseek`) keeps its host agent for transcript discovery
|
|
37
|
+
* and parsing (`claude`) and stamps the profile name on `harness`. Display
|
|
38
|
+
* prefers that stamp so a deepseek run is not listed as claude (PHNX-2935).
|
|
39
|
+
*/
|
|
40
|
+
export function sessionDisplayAgent(session) {
|
|
41
|
+
const harness = session.harness?.trim();
|
|
42
|
+
return harness || session.agent;
|
|
43
|
+
}
|
|
@@ -85,6 +85,8 @@ export interface PublishOptions {
|
|
|
85
85
|
export type ShareVisibility = 'public' | 'unlisted' | 'me' | 'org';
|
|
86
86
|
export interface PublishResult {
|
|
87
87
|
url: string;
|
|
88
|
+
/** URL-safe object name, explicit (`--slug`) or deterministically derived. */
|
|
89
|
+
slug?: string;
|
|
88
90
|
expiresAt?: string;
|
|
89
91
|
coverUrl?: string;
|
|
90
92
|
/** Server-enforced visibility stamped on the object. */
|
|
@@ -224,22 +226,17 @@ export declare function redactEmails(text: string): string;
|
|
|
224
226
|
export declare function scanShareContent(body: string | Buffer): SensitiveHit[];
|
|
225
227
|
/** Build the refuse message when the pre-publish scan finds sensitive content. */
|
|
226
228
|
export declare function formatSensitiveContentError(hits: SensitiveHit[]): string;
|
|
227
|
-
/** Derive a URL-safe slug from a filename
|
|
229
|
+
/** Derive a URL-safe slug from a filename, stripping its path and extension. */
|
|
228
230
|
export declare function slugify(name: string): string;
|
|
229
231
|
/** The project the file belongs to — git repo name, else the cwd's basename. */
|
|
230
232
|
export declare function detectProject(dir?: string): string;
|
|
231
233
|
/**
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
* The tail is 8 random bytes (64-bit, 16 hex chars). Reads are public — the URL is
|
|
237
|
-
* the only capability — so the nonce must be genuinely infeasible to brute-force,
|
|
238
|
-
* not merely unlisted; 64 bits puts a blind guess out of reach. (See docs/distribution.md
|
|
239
|
-
* §Security for the threat model and `--expire` for sensitive content.)
|
|
240
|
-
* `--slug` still publishes that exact slug, no view-id appended.
|
|
234
|
+
* Stable default slug for an artifact: prefer its HTML `<title>` or Markdown
|
|
235
|
+
* frontmatter `title:`, then fall back to its filename. The same artifact title
|
|
236
|
+
* always yields the same slug, so republishing without `--slug` updates the same
|
|
237
|
+
* URL. `--slug` remains an exact override.
|
|
241
238
|
*/
|
|
242
|
-
export declare function defaultSlug(filePath: string,
|
|
239
|
+
export declare function defaultSlug(filePath: string, body?: Buffer): string;
|
|
243
240
|
/**
|
|
244
241
|
* Best-effort OG cover: capture a screenshot, upload it as `<slug>.png`, and return
|
|
245
242
|
* the page body with og:image meta injected (+ the cover URL). All IO is injected
|
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
import { readFileSync } from 'node:fs';
|
|
9
9
|
import { basename } from 'node:path';
|
|
10
10
|
import { execFileSync } from 'node:child_process';
|
|
11
|
-
import { randomBytes } from 'node:crypto';
|
|
12
11
|
import { hostname as osHostname } from 'node:os';
|
|
13
12
|
import { readShareConfig } from './config.js';
|
|
14
13
|
import { resolveGitHubUsername } from '../git.js';
|
|
@@ -309,15 +308,17 @@ export function formatSensitiveContentError(hits) {
|
|
|
309
308
|
`Shares are world-readable by URL — pass --force to publish anyway, ` +
|
|
310
309
|
`or --unlisted --expire 12h to bound the blast radius.`);
|
|
311
310
|
}
|
|
312
|
-
/**
|
|
313
|
-
|
|
314
|
-
return (
|
|
315
|
-
.replace(/\.[^.]+$/, '')
|
|
316
|
-
.toLowerCase()
|
|
311
|
+
/** Normalize artifact title text into a URL-safe slug. */
|
|
312
|
+
function normalizeSlug(name) {
|
|
313
|
+
return (name.toLowerCase()
|
|
317
314
|
.replace(/[^a-z0-9]+/g, '-')
|
|
318
315
|
.replace(/^-+|-+$/g, '')
|
|
319
316
|
.slice(0, 60) || 'page');
|
|
320
317
|
}
|
|
318
|
+
/** Derive a URL-safe slug from a filename, stripping its path and extension. */
|
|
319
|
+
export function slugify(name) {
|
|
320
|
+
return normalizeSlug(basename(name).replace(/\.[^.]+$/, ''));
|
|
321
|
+
}
|
|
321
322
|
function sanitizeSlugPart(s) {
|
|
322
323
|
return sanitizeShareNamespace(s);
|
|
323
324
|
}
|
|
@@ -343,19 +344,13 @@ export function detectProject(dir = process.cwd()) {
|
|
|
343
344
|
return gitRepoName(dir) ?? (sanitizeSlugPart(basename(dir)) || 'share');
|
|
344
345
|
}
|
|
345
346
|
/**
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
* The tail is 8 random bytes (64-bit, 16 hex chars). Reads are public — the URL is
|
|
351
|
-
* the only capability — so the nonce must be genuinely infeasible to brute-force,
|
|
352
|
-
* not merely unlisted; 64 bits puts a blind guess out of reach. (See docs/distribution.md
|
|
353
|
-
* §Security for the threat model and `--expire` for sensitive content.)
|
|
354
|
-
* `--slug` still publishes that exact slug, no view-id appended.
|
|
347
|
+
* Stable default slug for an artifact: prefer its HTML `<title>` or Markdown
|
|
348
|
+
* frontmatter `title:`, then fall back to its filename. The same artifact title
|
|
349
|
+
* always yields the same slug, so republishing without `--slug` updates the same
|
|
350
|
+
* URL. `--slug` remains an exact override.
|
|
355
351
|
*/
|
|
356
|
-
export function defaultSlug(filePath,
|
|
357
|
-
|
|
358
|
-
return `${feature}-${randomBytes(8).toString('hex')}`;
|
|
352
|
+
export function defaultSlug(filePath, body) {
|
|
353
|
+
return body ? normalizeSlug(deriveLabel(filePath, body)) : slugify(filePath);
|
|
359
354
|
}
|
|
360
355
|
function guessContentType(filePath) {
|
|
361
356
|
if (/\.html?$/i.test(filePath))
|
|
@@ -459,7 +454,8 @@ export async function publishFile(filePath, opts = {}) {
|
|
|
459
454
|
}
|
|
460
455
|
export async function publishToEndpoint(filePath, endpoint, opts = {}) {
|
|
461
456
|
const username = await resolveShareUsername(opts);
|
|
462
|
-
|
|
457
|
+
let body = readFileSync(filePath);
|
|
458
|
+
const slugPart = (opts.slug ?? defaultSlug(filePath, body)).replace(/^\/+/, '');
|
|
463
459
|
const key = buildShareKey(username, slugPart);
|
|
464
460
|
const expiresAt = resolveExpire(opts.expire);
|
|
465
461
|
const visibility = resolveShareVisibility(opts);
|
|
@@ -472,7 +468,6 @@ export async function publishToEndpoint(filePath, endpoint, opts = {}) {
|
|
|
472
468
|
const res = await fetch(u, { method: 'PUT', headers: h, body: new Uint8Array(b) });
|
|
473
469
|
return { ok: res.ok, status: res.status, url: u };
|
|
474
470
|
});
|
|
475
|
-
let body = readFileSync(filePath);
|
|
476
471
|
let coverUrl;
|
|
477
472
|
const isHtml = /\.html?$/i.test(filePath);
|
|
478
473
|
if (isHtml) {
|
|
@@ -576,6 +571,7 @@ export async function publishToEndpoint(filePath, endpoint, opts = {}) {
|
|
|
576
571
|
}
|
|
577
572
|
return {
|
|
578
573
|
url: r.url ?? pageUrl,
|
|
574
|
+
slug: key.slice(key.indexOf('/') + 1),
|
|
579
575
|
expiresAt,
|
|
580
576
|
coverUrl,
|
|
581
577
|
label,
|
|
@@ -253,7 +253,20 @@ export default {
|
|
|
253
253
|
headers.set('cache-control', 'public, max-age=60');
|
|
254
254
|
if (visibility === 'unlisted') headers.set('X-Robots-Tag', 'noindex');
|
|
255
255
|
}
|
|
256
|
-
|
|
256
|
+
if (request.method === 'HEAD') return new Response(null, { status: 200, headers });
|
|
257
|
+
// HTML pages get an attribution bar injected at serve time (who shared it,
|
|
258
|
+
// what made it, when, and — the point — a visual VISIBILITY cue). All read
|
|
259
|
+
// from the object's stamped metadata; non-HTML assets (images, JSON, the OG
|
|
260
|
+
// cover) are served byte-for-byte. The body changes, so the R2 etag no
|
|
261
|
+
// longer matches — drop it rather than serve a lying validator.
|
|
262
|
+
const isHtmlPage = (headers.get('content-type') || '').indexOf('text/html') !== -1;
|
|
263
|
+
if (isHtmlPage) {
|
|
264
|
+
const rawHtml = await obj.text();
|
|
265
|
+
const withBar = injectAttributionBar(rawHtml, obj.customMetadata || {}, firstSeg);
|
|
266
|
+
headers.delete('etag');
|
|
267
|
+
return new Response(withBar, { status: 200, headers });
|
|
268
|
+
}
|
|
269
|
+
return new Response(obj.body, { status: 200, headers });
|
|
257
270
|
}
|
|
258
271
|
|
|
259
272
|
if (request.method === 'DELETE') {
|
|
@@ -473,6 +486,73 @@ function escapeHtml(s) {
|
|
|
473
486
|
.replace(/"/g, '"');
|
|
474
487
|
}
|
|
475
488
|
|
|
489
|
+
// The attribution bar: a slim strip prepended to every served HTML page that
|
|
490
|
+
// makes the object's stamped metadata visible — who shared it, what made it,
|
|
491
|
+
// when, and (the whole point) a color-coded VISIBILITY cue Google-Drive style.
|
|
492
|
+
// All values come from customMetadata; nothing new is stamped at publish time,
|
|
493
|
+
// so this is a pure Worker change deployable with 'agents artifacts share update'.
|
|
494
|
+
var VIS_ICON = {
|
|
495
|
+
me: '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="11" width="16" height="9" rx="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg>',
|
|
496
|
+
org: '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 21h18M6 21V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v17M14 9h4a1 1 0 0 1 1 1v11"/></svg>',
|
|
497
|
+
unlisted: '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1"/><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"/></svg>',
|
|
498
|
+
public: '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a15 15 0 0 1 0 18M12 3a15 15 0 0 0 0 18"/></svg>',
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
function visibilityChip(visibility, orgDomain) {
|
|
502
|
+
if (visibility === 'me') return { icon: VIS_ICON.me, label: 'Only you', color: '#f59e0b' };
|
|
503
|
+
if (visibility === 'org') return { icon: VIS_ICON.org, label: 'Anyone at ' + escapeHtml(orgDomain || 'your organization'), color: '#5b9dff' };
|
|
504
|
+
if (visibility === 'unlisted') return { icon: VIS_ICON.unlisted, label: 'Unlisted', color: '#9aa0a6' };
|
|
505
|
+
return { icon: VIS_ICON.public, label: 'Public', color: '#30a46c' };
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function renderAttributionBar(meta, handle) {
|
|
509
|
+
var cm = meta || {};
|
|
510
|
+
var chip = visibilityChip(cm.visibility || 'public', cm.org_domain);
|
|
511
|
+
var left = '';
|
|
512
|
+
// The handle is already the public URL namespace, so surfacing it leaks nothing new.
|
|
513
|
+
if (handle) left += 'Shared by <strong>' + escapeHtml(handle) + '</strong>';
|
|
514
|
+
if (cm.agent) left += (left ? '<span class="ash-dot">·</span>' : '') + 'Made with ' + escapeHtml(cm.agent);
|
|
515
|
+
var right = '<span class="ash-chip" style="color:' + chip.color + ';border-color:' + chip.color + '66">' + chip.icon + '<span>' + chip.label + '</span></span>';
|
|
516
|
+
if (cm.date) right += '<span class="ash-date">' + escapeHtml(cm.date) + '</span>';
|
|
517
|
+
// The load-bearing background + base colour ride an INLINE style so the host
|
|
518
|
+
// page's own CSS can never wash the bar out (inline beats a page stylesheet);
|
|
519
|
+
// the rest is a namespaced style block with !important on every colour so a
|
|
520
|
+
// broad page rule (span selector to black, etc.) cannot make the text vanish.
|
|
521
|
+
// FIXED, full-viewport-width chrome (not sticky-inside-body — that inherited the
|
|
522
|
+
// page's max-width/margins and rendered as a floating box). html padding-top
|
|
523
|
+
// pushes the whole page down by the bar's height; flex-wrap:nowrap + ellipsis on
|
|
524
|
+
// the left keeps the bar exactly one line tall so the push height stays correct.
|
|
525
|
+
return '<div class="agents-share-bar" role="contentinfo" aria-label="Sharing details" ' +
|
|
526
|
+
'style="background:#0b0b0c !important;color:#e8e8e8 !important">' +
|
|
527
|
+
'<style>' +
|
|
528
|
+
'html{padding-top:38px !important}' +
|
|
529
|
+
'.agents-share-bar{all:initial;position:fixed !important;top:0 !important;left:0 !important;right:0 !important;width:100% !important;z-index:2147483647 !important;box-sizing:border-box;display:flex !important;align-items:center;gap:12px;flex-wrap:nowrap;' +
|
|
530
|
+
'padding:8px 16px !important;background:#0b0b0c !important;color:#e8e8e8 !important;border-bottom:1px solid #23232a !important;box-shadow:0 1px 3px rgba(0,0,0,.35) !important;' +
|
|
531
|
+
'font:13px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,system-ui,sans-serif !important}' +
|
|
532
|
+
'.agents-share-bar *{box-sizing:border-box;font-family:inherit}' +
|
|
533
|
+
'.agents-share-bar .ash-left{flex:1 1 auto;min-width:0;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;color:#cfd3d9 !important}' +
|
|
534
|
+
'.agents-share-bar strong{color:#fff !important;font-weight:600}' +
|
|
535
|
+
'.agents-share-bar .ash-dot{opacity:.4;margin:0 4px}' +
|
|
536
|
+
'.agents-share-bar .ash-right{flex:none;display:flex;align-items:center;gap:12px}' +
|
|
537
|
+
'.agents-share-bar .ash-date{color:#9096a0 !important}' +
|
|
538
|
+
'.agents-share-bar .ash-chip{display:inline-flex;align-items:center;gap:6px;padding:3px 10px;border-radius:999px;border:1px solid;font-weight:600;font-size:12px;white-space:nowrap}' +
|
|
539
|
+
'.agents-share-bar .ash-chip svg{flex:none;vertical-align:middle}' +
|
|
540
|
+
'</style>' +
|
|
541
|
+
'<span class="ash-left">' + left + '</span>' +
|
|
542
|
+
'<span class="ash-right">' + right + '</span>' +
|
|
543
|
+
'</div>';
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function injectAttributionBar(html, meta, handle) {
|
|
547
|
+
var bar = renderAttributionBar(meta, handle);
|
|
548
|
+
var m = /<body[^>]*>/i.exec(html);
|
|
549
|
+
if (m) {
|
|
550
|
+
var at = m.index + m[0].length;
|
|
551
|
+
return html.slice(0, at) + bar + html.slice(at);
|
|
552
|
+
}
|
|
553
|
+
return bar + html;
|
|
554
|
+
}
|
|
555
|
+
|
|
476
556
|
function json(body, status) {
|
|
477
557
|
return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
|
|
478
558
|
}
|
|
@@ -507,23 +587,35 @@ function phoenixHandle(auth) {
|
|
|
507
587
|
// First writer of a handle owns it. Later PUTs from the same userId are fine;
|
|
508
588
|
// a different userId whose email local-part collides gets 409, not a silent overwrite.
|
|
509
589
|
async function assertHandleOwner(bucket, handle, userId) {
|
|
510
|
-
//
|
|
511
|
-
//
|
|
512
|
-
//
|
|
590
|
+
// The __handles/<handle> claim object is the authoritative first-writer record
|
|
591
|
+
// of ownership. When it exists it decides ownership OUTRIGHT: the recorded
|
|
592
|
+
// userId may write, anyone else is refused. Consult it FIRST — a stray page
|
|
593
|
+
// under the namespace stamped with a different owner (e.g. a BYO WRITE_TOKEN
|
|
594
|
+
// publish, which stamps owner = SHARE_NAMESPACE rather than a userId, or a page
|
|
595
|
+
// published under the same human's earlier userId before a re-auth) must not
|
|
596
|
+
// lock the rightful claim holder out of their own handle (PHNX-3291).
|
|
597
|
+
const key = '__handles/' + handle;
|
|
598
|
+
const existing = await bucket.get(key);
|
|
599
|
+
if (existing) {
|
|
600
|
+
const claimed = existing.customMetadata && existing.customMetadata.userId;
|
|
601
|
+
if (claimed && claimed !== userId) {
|
|
602
|
+
return { error: json({ error: 'handle taken', handle: handle }, 409) };
|
|
603
|
+
}
|
|
604
|
+
return {};
|
|
605
|
+
}
|
|
606
|
+
// No claim object yet — fall back to the page-owner scan so a colliding email
|
|
607
|
+
// local-part cannot PUT/DELETE another account's objects in the window before
|
|
608
|
+
// the claim exists. (claimHandle writes the claim on the first Phoenix PUT.)
|
|
609
|
+
// Only a DIFFERENT Phoenix userId blocks: a BYO WRITE_TOKEN publish stamps
|
|
610
|
+
// owner = the namespace (=== handle) rather than a userId, so it is not a rival
|
|
611
|
+
// identity and must not lock the handle's first Phoenix claimant out (PHNX-3291).
|
|
513
612
|
const list = await bucket.list({ prefix: handle + '/', include: ['customMetadata'] });
|
|
514
613
|
for (const o of list.objects || []) {
|
|
515
614
|
const owner = o.customMetadata && o.customMetadata.owner;
|
|
516
|
-
if (owner && owner !== userId) {
|
|
615
|
+
if (owner && owner !== userId && owner !== handle) {
|
|
517
616
|
return { error: json({ error: 'handle taken', handle: handle }, 409) };
|
|
518
617
|
}
|
|
519
618
|
}
|
|
520
|
-
const key = '__handles/' + handle;
|
|
521
|
-
const existing = await bucket.get(key);
|
|
522
|
-
if (!existing) return {};
|
|
523
|
-
const claimed = existing.customMetadata && existing.customMetadata.userId;
|
|
524
|
-
if (claimed && claimed !== userId) {
|
|
525
|
-
return { error: json({ error: 'handle taken', handle: handle }, 409) };
|
|
526
|
-
}
|
|
527
619
|
return {};
|
|
528
620
|
}
|
|
529
621
|
|
package/dist/lib/state.d.ts
CHANGED
|
@@ -391,6 +391,14 @@ export declare function getDeviceMetaPath(): string;
|
|
|
391
391
|
*/
|
|
392
392
|
export declare function getVersionResourcesPath(): string;
|
|
393
393
|
export declare function withMetaLock<T>(fn: () => T): T;
|
|
394
|
+
/**
|
|
395
|
+
* Write `meta` to disk (central + device docs + pins) WITHOUT taking the meta
|
|
396
|
+
* lock — the caller must already hold it via {@link withMetaLock}. Exported so a
|
|
397
|
+
* writer that needs to read fresh state, decide, and commit within a SINGLE lock
|
|
398
|
+
* acquisition (e.g. browser tombstone eviction) can do so without the
|
|
399
|
+
* read-snapshot-then-separately-lock race that {@link updateMeta} would impose.
|
|
400
|
+
*/
|
|
401
|
+
export declare function writeMetaUnlocked(meta: Meta): void;
|
|
394
402
|
/**
|
|
395
403
|
* Read and cache ~/.agents/agents.yaml, migrating from legacy locations if needed.
|
|
396
404
|
*
|
package/dist/lib/state.js
CHANGED
|
@@ -876,6 +876,9 @@ const META_KEY_SCOPE = {
|
|
|
876
876
|
deviceRoutines: 'device',
|
|
877
877
|
deviceConfig: 'device',
|
|
878
878
|
deviceBrowser: 'device',
|
|
879
|
+
deviceFleet: 'device',
|
|
880
|
+
deviceHosts: 'device',
|
|
881
|
+
deviceAccounts: 'device',
|
|
879
882
|
// Central — synced via agents.yaml.
|
|
880
883
|
accounts: 'central',
|
|
881
884
|
run: 'central',
|
|
@@ -915,6 +918,35 @@ const KNOWN_META_KEYS = new Set([
|
|
|
915
918
|
// first registry read can migrate it into this device's file and delete it.
|
|
916
919
|
'browser',
|
|
917
920
|
]);
|
|
921
|
+
/**
|
|
922
|
+
* Rewrite a frozen `agents.yaml` header to the current {@link META_HEADER}.
|
|
923
|
+
*
|
|
924
|
+
* `serializeCentral` parses the existing file to preserve its hand-written body
|
|
925
|
+
* comments, but that also preserves the leading metadata header verbatim, so a
|
|
926
|
+
* top-level file written before the agi-cli rename (or before the `$schema` line
|
|
927
|
+
* existed) keeps its stale header forever — every freshly-written device doc gets
|
|
928
|
+
* the current header while the shared file is left behind (PHNX-3315).
|
|
929
|
+
*
|
|
930
|
+
* The header is healed TEXTUALLY, on the already-serialized string, rather than
|
|
931
|
+
* via `doc.commentBefore`: the `yaml` library folds the whole leading comment
|
|
932
|
+
* block onto the FIRST key's `commentBefore` when that key already carries a
|
|
933
|
+
* hand-written comment, so the header is not reliably the document comment — but
|
|
934
|
+
* it is always the top block of the output. Strip a leading `agents-cli metadata`
|
|
935
|
+
* header (any pre-rename variant, with or without the `$schema` line — the URL
|
|
936
|
+
* and schema lines are matched specifically so a hand-written body comment is
|
|
937
|
+
* never mistaken for a header line) and prepend the canonical header. A file with
|
|
938
|
+
* no recognizable header simply gains one. Body comments, which sit below the
|
|
939
|
+
* blank line that terminates the header, are untouched.
|
|
940
|
+
*/
|
|
941
|
+
function healMetaHeader(serialized) {
|
|
942
|
+
const headerBlock = /^# agents-cli metadata\n# Auto-generated - do not edit manually\n(?:# (?:https:\/\/github\.com\/phnx-labs\/[^\n]*|yaml-language-server: \$schema=[^\n]*)\n)*\n?/;
|
|
943
|
+
const stripped = serialized.replace(headerBlock, '');
|
|
944
|
+
// No metadata header present (replace was a no-op) → leave the file exactly as
|
|
945
|
+
// it is. We heal a STALE header; we never prepend one to a file that never had
|
|
946
|
+
// it (that would rewrite a hand-authored, headerless central file on the first
|
|
947
|
+
// real central change). A current header round-trips to the identical bytes.
|
|
948
|
+
return stripped === serialized ? serialized : META_HEADER + stripped;
|
|
949
|
+
}
|
|
918
950
|
/**
|
|
919
951
|
* Serialize the central (synced) meta to `agents.yaml` WITHOUT destroying the
|
|
920
952
|
* hand-written comments in the committed file.
|
|
@@ -973,20 +1005,37 @@ function serializeCentral(central) {
|
|
|
973
1005
|
}
|
|
974
1006
|
}
|
|
975
1007
|
// No central field changed → keep the file byte-identical (comments intact), so
|
|
976
|
-
// writeIfChanged skips it and the churn loop never starts.
|
|
1008
|
+
// writeIfChanged skips it and the churn loop never starts. A device-only write
|
|
1009
|
+
// (pins/routines/etc. routed elsewhere) reaches here with changed=false and
|
|
1010
|
+
// MUST NOT rewrite the shared file — header healing waits for a genuine central
|
|
1011
|
+
// change below rather than dirtying agents.yaml on an unrelated write, which is
|
|
1012
|
+
// the very churn that wedges `agents sync` and blocks fleet pulls.
|
|
977
1013
|
if (!changed)
|
|
978
1014
|
return existing;
|
|
979
|
-
// Everything cleared → header only (never leave a flow `{}` behind).
|
|
980
|
-
//
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
//
|
|
984
|
-
//
|
|
985
|
-
// (
|
|
986
|
-
|
|
1015
|
+
// Everything cleared → header only (never leave a flow `{}` behind). Byte-stable
|
|
1016
|
+
// when the file is already exactly the current header.
|
|
1017
|
+
if (isEmpty)
|
|
1018
|
+
return existing === META_HEADER ? existing : META_HEADER;
|
|
1019
|
+
// A central key changed: serialize the edited doc and heal a frozen header on
|
|
1020
|
+
// the result. stringifyDoc still normalizes a legacy flow root (`{}`) to block,
|
|
1021
|
+
// so edited nodes do not render flow (`disabledCommands: [ teams ]` instead of a
|
|
1022
|
+
// `- teams` block list), but it no longer forces block on a normal document —
|
|
1023
|
+
// that flattened committed flow sequences and made this writer disagree with
|
|
1024
|
+
// feed.ts/activity.ts/migrate.ts on the same file (RUSH-2505). parseDocument
|
|
1025
|
+
// still preserves body comments + key ordering; healMetaHeader is a no-op unless
|
|
1026
|
+
// a stale metadata header is actually present, so a headerless central file is
|
|
1027
|
+
// updated in place without gaining one.
|
|
1028
|
+
return healMetaHeader(stringifyDoc(doc));
|
|
987
1029
|
}
|
|
988
|
-
|
|
989
|
-
|
|
1030
|
+
/**
|
|
1031
|
+
* Write `meta` to disk (central + device docs + pins) WITHOUT taking the meta
|
|
1032
|
+
* lock — the caller must already hold it via {@link withMetaLock}. Exported so a
|
|
1033
|
+
* writer that needs to read fresh state, decide, and commit within a SINGLE lock
|
|
1034
|
+
* acquisition (e.g. browser tombstone eviction) can do so without the
|
|
1035
|
+
* read-snapshot-then-separately-lock race that {@link updateMeta} would impose.
|
|
1036
|
+
*/
|
|
1037
|
+
export function writeMetaUnlocked(meta) {
|
|
1038
|
+
const { agents, isolatedAgents, versions, deviceRoutines, deviceConfig, deviceBrowser, deviceFleet, deviceHosts, deviceAccounts, projectRoot, ...central } = meta;
|
|
990
1039
|
// Write the machine-local files FIRST, then strip central — so a crash mid-write
|
|
991
1040
|
// never removes pins/versions from central before they're persisted elsewhere.
|
|
992
1041
|
const hasAgents = !!agents && Object.keys(agents).length > 0;
|
|
@@ -1049,6 +1098,43 @@ function writeMetaUnlocked(meta) {
|
|
|
1049
1098
|
doc.browser = deviceBrowser;
|
|
1050
1099
|
else
|
|
1051
1100
|
delete doc.browser;
|
|
1101
|
+
// PHNX-3315 device-scoped fleet/hosts/accounts blocks. Each is this box's OWN
|
|
1102
|
+
// slice; the effective fleet view is unioned across every device doc at read
|
|
1103
|
+
// time (lib/devices/device-docs.ts). Empty slices are dropped so a box that
|
|
1104
|
+
// has made no decision leaves no key behind (no committed empty maps).
|
|
1105
|
+
const fleetDiscovery = deviceFleet?.discovery && Object.keys(deviceFleet.discovery).length > 0
|
|
1106
|
+
? deviceFleet.discovery : undefined;
|
|
1107
|
+
const fleetIgnored = deviceFleet?.ignored && deviceFleet.ignored.length > 0
|
|
1108
|
+
? deviceFleet.ignored : undefined;
|
|
1109
|
+
if (fleetDiscovery || fleetIgnored) {
|
|
1110
|
+
const df = {};
|
|
1111
|
+
if (fleetDiscovery)
|
|
1112
|
+
df.discovery = fleetDiscovery;
|
|
1113
|
+
if (fleetIgnored)
|
|
1114
|
+
df.ignored = fleetIgnored;
|
|
1115
|
+
doc.fleet = df;
|
|
1116
|
+
}
|
|
1117
|
+
else
|
|
1118
|
+
delete doc.fleet;
|
|
1119
|
+
const hasDeviceHosts = !!deviceHosts && Object.keys(deviceHosts).length > 0;
|
|
1120
|
+
if (hasDeviceHosts)
|
|
1121
|
+
doc.hosts = deviceHosts;
|
|
1122
|
+
else
|
|
1123
|
+
delete doc.hosts;
|
|
1124
|
+
const accountsNative = deviceAccounts?.native && Object.keys(deviceAccounts.native).length > 0
|
|
1125
|
+
? deviceAccounts.native : undefined;
|
|
1126
|
+
const accountsBindings = deviceAccounts?.bindings && Object.keys(deviceAccounts.bindings).length > 0
|
|
1127
|
+
? deviceAccounts.bindings : undefined;
|
|
1128
|
+
if (accountsNative || accountsBindings) {
|
|
1129
|
+
const da = {};
|
|
1130
|
+
if (accountsNative)
|
|
1131
|
+
da.native = accountsNative;
|
|
1132
|
+
if (accountsBindings)
|
|
1133
|
+
da.bindings = accountsBindings;
|
|
1134
|
+
doc.accounts = da;
|
|
1135
|
+
}
|
|
1136
|
+
else
|
|
1137
|
+
delete doc.accounts;
|
|
1052
1138
|
const hasProjectRoot = typeof projectRoot === 'string' && projectRoot.length > 0;
|
|
1053
1139
|
if (hasProjectRoot)
|
|
1054
1140
|
doc.projectRoot = projectRoot;
|
|
@@ -1121,6 +1207,52 @@ function overlayMachineLocal(meta) {
|
|
|
1121
1207
|
if (dm?.config && typeof dm.config === 'object' && !Array.isArray(dm.config)) {
|
|
1122
1208
|
meta.deviceConfig = { ...meta.deviceConfig, ...dm.config };
|
|
1123
1209
|
}
|
|
1210
|
+
// PHNX-3315: this box's own device-scoped fleet/hosts/accounts slices.
|
|
1211
|
+
// These populate the `device*` keys the writers read-modify-write; the
|
|
1212
|
+
// effective UNION across all boxes is computed separately by
|
|
1213
|
+
// lib/devices/device-docs.ts, not here (this overlay is this box only).
|
|
1214
|
+
// A malformed block on THIS box's own doc is a HARD error, exactly like
|
|
1215
|
+
// `routines` below and the cross-box union readers (device-docs.ts): a
|
|
1216
|
+
// silent drop would let the next writeMetaUnlocked round-trip overwrite the
|
|
1217
|
+
// whole block with just the new entry, discarding the rest on this box's
|
|
1218
|
+
// tracked file (PHNX-3315).
|
|
1219
|
+
const isMap = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
1220
|
+
const dmRaw = dm;
|
|
1221
|
+
if (dmRaw.fleet !== undefined) {
|
|
1222
|
+
if (!isMap(dmRaw.fleet))
|
|
1223
|
+
throw new Error(`Device config corrupted at ${devicePath}: fleet must be a map.`);
|
|
1224
|
+
const df = dmRaw.fleet;
|
|
1225
|
+
if (df.discovery !== undefined && !isMap(df.discovery)) {
|
|
1226
|
+
throw new Error(`Device config corrupted at ${devicePath}: fleet.discovery must be a map.`);
|
|
1227
|
+
}
|
|
1228
|
+
if (df.ignored !== undefined && !Array.isArray(df.ignored)) {
|
|
1229
|
+
throw new Error(`Device config corrupted at ${devicePath}: fleet.ignored must be a list.`);
|
|
1230
|
+
}
|
|
1231
|
+
const discovery = df.discovery;
|
|
1232
|
+
const ignored = df.ignored;
|
|
1233
|
+
if (discovery || ignored)
|
|
1234
|
+
meta.deviceFleet = { ...(discovery ? { discovery } : {}), ...(ignored ? { ignored } : {}) };
|
|
1235
|
+
}
|
|
1236
|
+
if (dmRaw.hosts !== undefined) {
|
|
1237
|
+
if (!isMap(dmRaw.hosts))
|
|
1238
|
+
throw new Error(`Device config corrupted at ${devicePath}: hosts must be a map.`);
|
|
1239
|
+
meta.deviceHosts = { ...meta.deviceHosts, ...dmRaw.hosts };
|
|
1240
|
+
}
|
|
1241
|
+
if (dmRaw.accounts !== undefined) {
|
|
1242
|
+
if (!isMap(dmRaw.accounts))
|
|
1243
|
+
throw new Error(`Device config corrupted at ${devicePath}: accounts must be a map.`);
|
|
1244
|
+
const acc = dmRaw.accounts;
|
|
1245
|
+
if (acc.native !== undefined && !isMap(acc.native)) {
|
|
1246
|
+
throw new Error(`Device config corrupted at ${devicePath}: accounts.native must be a map.`);
|
|
1247
|
+
}
|
|
1248
|
+
if (acc.bindings !== undefined && !isMap(acc.bindings)) {
|
|
1249
|
+
throw new Error(`Device config corrupted at ${devicePath}: accounts.bindings must be a map.`);
|
|
1250
|
+
}
|
|
1251
|
+
const native = acc.native;
|
|
1252
|
+
const bindings = acc.bindings;
|
|
1253
|
+
if (native || bindings)
|
|
1254
|
+
meta.deviceAccounts = { ...(native ? { native } : {}), ...(bindings ? { bindings } : {}) };
|
|
1255
|
+
}
|
|
1124
1256
|
if (Object.prototype.hasOwnProperty.call(dm, 'routines')) {
|
|
1125
1257
|
if (!Array.isArray(dm.routines) || dm.routines.some((name) => typeof name !== 'string')) {
|
|
1126
1258
|
throw new Error(`Device config corrupted at ${devicePath}: routines must be a string list.`);
|
|
@@ -64,8 +64,18 @@ export interface SystemRepoStatus {
|
|
|
64
64
|
/** True when the dir isn't a git repo or has no upstream — behind is unknown. */
|
|
65
65
|
unknown: boolean;
|
|
66
66
|
}
|
|
67
|
+
export interface UserRepoStatus {
|
|
68
|
+
dir: string;
|
|
69
|
+
/**
|
|
70
|
+
* True when `~/.agents` exists but is not a git repo (or is a repo with no
|
|
71
|
+
* `origin`) — a partial install `agents repo sync user` will adopt in place
|
|
72
|
+
* (PHNX-3301). A DISTINCT drift state, not a per-agent "N missing" count.
|
|
73
|
+
*/
|
|
74
|
+
notGitRepo: boolean;
|
|
75
|
+
}
|
|
67
76
|
export interface UnifiedSyncStatus {
|
|
68
77
|
system: SystemRepoStatus;
|
|
78
|
+
user: UserRepoStatus;
|
|
69
79
|
agents: AgentVersionStatus[];
|
|
70
80
|
totals: {
|
|
71
81
|
drifted: number;
|
|
@@ -93,6 +103,13 @@ export interface SyncStatusOptions {
|
|
|
93
103
|
* the menu-bar surfaces; we read it once, here, so every surface agrees.
|
|
94
104
|
*/
|
|
95
105
|
export declare function getSystemRepoStatus(): Promise<SystemRepoStatus>;
|
|
106
|
+
/**
|
|
107
|
+
* Detect whether `~/.agents` (the user config layer) is git-backed. A partial
|
|
108
|
+
* install — runtime state present but no `.git` (or no `origin`) — is a distinct
|
|
109
|
+
* drift state that `agents repo sync user` heals by adopting in place (PHNX-3301),
|
|
110
|
+
* surfaced separately from per-version resource gaps. Purely local; no network.
|
|
111
|
+
*/
|
|
112
|
+
export declare function getUserRepoStatus(): Promise<UserRepoStatus>;
|
|
96
113
|
/**
|
|
97
114
|
* Compute unified sync status across the fleet. Resolves against non-project
|
|
98
115
|
* layers only (`excludeProject: true`) — the GLOBAL version home is never
|