@phnx-labs/agents-cli 1.22.60 → 1.22.62

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +6 -0
  3. package/dist/cli/command-registry.d.ts +1 -0
  4. package/dist/cli/command-registry.js +2 -0
  5. package/dist/commands/browser.js +9 -4
  6. package/dist/commands/doctor.js +1 -1
  7. package/dist/commands/exec.js +35 -1
  8. package/dist/commands/feed.js +3 -1
  9. package/dist/commands/harness-hooks.d.ts +55 -0
  10. package/dist/commands/harness-hooks.js +104 -0
  11. package/dist/commands/harness-wizard.d.ts +33 -14
  12. package/dist/commands/harness-wizard.js +53 -23
  13. package/dist/commands/harness.d.ts +14 -0
  14. package/dist/commands/harness.js +86 -5
  15. package/dist/commands/monitors.js +3 -3
  16. package/dist/commands/reminders.d.ts +9 -0
  17. package/dist/commands/reminders.js +49 -0
  18. package/dist/commands/run-account-picker.d.ts +14 -0
  19. package/dist/commands/run-account-picker.js +13 -0
  20. package/dist/commands/send.js +4 -1
  21. package/dist/commands/teams.d.ts +1 -1
  22. package/dist/commands/teams.js +9 -3
  23. package/dist/index.js +9 -0
  24. package/dist/lib/accounting/rotate.d.ts +63 -0
  25. package/dist/lib/accounting/rotate.js +229 -13
  26. package/dist/lib/browser/drivers/local.d.ts +11 -0
  27. package/dist/lib/browser/drivers/local.js +26 -0
  28. package/dist/lib/browser/profiles.js +8 -6
  29. package/dist/lib/browser/service.d.ts +12 -8
  30. package/dist/lib/browser/service.js +38 -10
  31. package/dist/lib/channels/owner-forward.d.ts +14 -8
  32. package/dist/lib/channels/owner-forward.js +9 -5
  33. package/dist/lib/channels/registry.d.ts +2 -0
  34. package/dist/lib/channels/send.js +13 -1
  35. package/dist/lib/claude-statusline.d.ts +14 -1
  36. package/dist/lib/claude-statusline.js +27 -2
  37. package/dist/lib/daemon/runner.js +17 -2
  38. package/dist/lib/devices/doctor-findings.d.ts +1 -1
  39. package/dist/lib/devices/doctor-findings.js +22 -4
  40. package/dist/lib/doctor-diff.d.ts +21 -5
  41. package/dist/lib/doctor-diff.js +242 -76
  42. package/dist/lib/feed/events.d.ts +1 -1
  43. package/dist/lib/feed/events.js +25 -16
  44. package/dist/lib/feed-broadcast.js +5 -12
  45. package/dist/lib/github/gh-overload.d.ts +58 -0
  46. package/dist/lib/github/gh-overload.js +246 -0
  47. package/dist/lib/github/rest.d.ts +64 -0
  48. package/dist/lib/github/rest.js +111 -0
  49. package/dist/lib/harness-connection-test.d.ts +57 -0
  50. package/dist/lib/harness-connection-test.js +80 -0
  51. package/dist/lib/heal.js +8 -3
  52. package/dist/lib/humans.d.ts +9 -0
  53. package/dist/lib/humans.js +29 -8
  54. package/dist/lib/installations/shims.d.ts +22 -0
  55. package/dist/lib/installations/shims.js +104 -0
  56. package/dist/lib/linear-project-counts.js +8 -0
  57. package/dist/lib/linear-rate-limit.d.ts +26 -0
  58. package/dist/lib/linear-rate-limit.js +163 -0
  59. package/dist/lib/mcp.d.ts +9 -0
  60. package/dist/lib/mcp.js +37 -1
  61. package/dist/lib/notify.d.ts +3 -0
  62. package/dist/lib/notify.js +63 -21
  63. package/dist/lib/open-url.js +5 -3
  64. package/dist/lib/permissions.d.ts +28 -0
  65. package/dist/lib/permissions.js +156 -1
  66. package/dist/lib/refresh.js +9 -1
  67. package/dist/lib/reminders.d.ts +29 -0
  68. package/dist/lib/reminders.js +88 -0
  69. package/dist/lib/resource-content-diff.d.ts +33 -0
  70. package/dist/lib/resource-content-diff.js +103 -0
  71. package/dist/lib/rules/compile.d.ts +7 -0
  72. package/dist/lib/rules/compile.js +7 -1
  73. package/dist/lib/session/active.d.ts +41 -4
  74. package/dist/lib/session/active.js +58 -7
  75. package/dist/lib/session/host-link.d.ts +22 -0
  76. package/dist/lib/session/host-link.js +40 -4
  77. package/dist/lib/session/trajectory.d.ts +42 -0
  78. package/dist/lib/session/trajectory.js +46 -27
  79. package/dist/lib/ssh-exec.d.ts +30 -0
  80. package/dist/lib/ssh-exec.js +37 -5
  81. package/dist/lib/startup/command-registry.js +1 -1
  82. package/dist/lib/subagents-registry.d.ts +18 -0
  83. package/dist/lib/subagents-registry.js +79 -0
  84. package/dist/lib/teams/agents.d.ts +12 -0
  85. package/dist/lib/teams/agents.js +51 -0
  86. package/dist/lib/traces/schema2-build.d.ts +85 -0
  87. package/dist/lib/traces/schema2-build.js +637 -0
  88. package/dist/lib/traces/schema2-danger.d.ts +36 -0
  89. package/dist/lib/traces/schema2-danger.js +185 -0
  90. package/dist/lib/traces/schema2.d.ts +149 -0
  91. package/dist/lib/traces/schema2.js +20 -0
  92. package/dist/lib/traces/sync.d.ts +93 -0
  93. package/dist/lib/traces/sync.js +75 -22
  94. package/dist/lib/traces/worker-template.js +5 -0
  95. package/dist/lib/uninstall.js +10 -1
  96. package/dist/lib/workflows.d.ts +11 -0
  97. package/dist/lib/workflows.js +67 -8
  98. package/package.json +1 -1
@@ -9,21 +9,44 @@
9
9
  * - missing — resolved source exists, not present in home
10
10
  * - extra — present in home, no source in any layer
11
11
  *
12
- * Coverage:
12
+ * Coverage — every kind `syncResourcesToVersion` writes is content-aware, so a
13
+ * byte change under an unchanged name is `diff`, never a false `ok` (PHNX-3504):
13
14
  * commands, skills, hooks, rules — full content compare with source layer.
14
- * mcp, permissions, subagents, plugins, promptcuts — presence-only.
15
+ * mcp — structural compare of the home server def vs resolved source.
16
+ * subagents — re-render source through the registry transform, byte-compare.
17
+ * workflows — layout-aware per-harness content compare (dir tree or file).
18
+ * memory — knowledge facts (~/.agents/memory/*.md), per-fact byte-compare.
19
+ * plugins — per-item content compare of the marketplace mirror.
20
+ * permissions — per-rule compare in the harness's native vocabulary for the
21
+ * representable harnesses (claude/opencode/cursor/droid/openclaw); the lossy
22
+ * TOML/flag harnesses stay presence-only with an honest `detail`, never a
23
+ * faked `ok`.
24
+ * A completeness test binds `DOCTOR_ALL_KINDS` to the writer set so a future
25
+ * synced kind cannot silently become a blind spot. `promptcuts` is NOT a kind —
26
+ * it is not version-scoped, so there is nothing per-home to diff.
15
27
  *
16
28
  * Intentional asymmetries (must mirror sync):
17
29
  * - hooks ignore the project layer (`syncResourcesToVersion` skips
18
30
  * project/.agents/hooks/ for safety).
19
- * - rules/AGENTS.md on agents without native @-import support is compared
20
- * against the compiled artifact, not the raw source file.
31
+ * - rules/AGENTS.md is compared against the preset composition the rules writer
32
+ * emits (`composeRulesFromState`), re-rendered from the current `subrules/`
33
+ * fragments — so a fragment edit is caught even though `rules/AGENTS.md`
34
+ * never changed. A non-@-import home compiled by `agents refresh-rules`
35
+ * carries a leading `COMPILED_HEADER`, which is stripped before comparing so
36
+ * a header-compiled home still reconciles (the header is not source content).
21
37
  */
22
38
  import * as fs from 'fs';
23
39
  import * as path from 'path';
24
- import { AGENTS, agentConfigDirName } from './agents.js';
25
- import { getProjectAgentsDir, getUserAgentsDir, getSystemAgentsDir, getEnabledExtraRepos, getResolvedRulesDir, getUserRulesDir, getEffectivePromptcutsPath, getActiveRulesPreset, } from './state.js';
40
+ import { AGENTS, agentConfigDirName, getMcpConfigPathForHome } from './agents.js';
41
+ import { getProjectAgentsDir, getUserAgentsDir, getSystemAgentsDir, getEnabledExtraRepos, getResolvedRulesDir, getUserRulesDir, getActiveRulesPreset, } from './state.js';
26
42
  import { composeRulesFromState } from './rules/compose.js';
43
+ import { COMPILED_HEADER, supportsRulesImports } from './rules/compile.js';
44
+ import { dirsContentMatch, filesContentMatch } from './resource-content-diff.js';
45
+ import { subagentContentMatches } from './subagents-registry.js';
46
+ import { getMcpServersByName, mcpServerMatches } from './mcp.js';
47
+ import { permissionsGroupMatches, PERMISSIONS_REPRESENTABLE } from './permissions.js';
48
+ import { workflowContentMatches, resolveWorkflowRef } from './workflows.js';
49
+ import { listMemoryFacts, memoryTargetDir } from './memory.js';
27
50
  import { getAvailableResources, getActuallySyncedResources, getVersionHomePath, compareVersions, } from './installations/versions.js';
28
51
  import { discoverPlugins, marketplaceSpecForName } from './plugins/plugins.js';
29
52
  import { pluginInstallDir, repairableManifestFields } from './plugins/plugin-marketplace.js';
@@ -47,7 +70,8 @@ const ALL_KINDS = [
47
70
  'permissions',
48
71
  'subagents',
49
72
  'plugins',
50
- 'promptcuts',
73
+ 'workflows',
74
+ 'memory',
51
75
  ];
52
76
  function normalize(content) {
53
77
  return content.replace(/\r\n/g, '\n').trim();
@@ -121,6 +145,36 @@ function buildLayerBases(cwd, kind, opts = {}) {
121
145
  out.push({ layer: 'extra', path: path.join(e.dir, kind), alias: e.alias });
122
146
  return out;
123
147
  }
148
+ /**
149
+ * Resolve directory-shaped source resources of `kind` by name across the layers
150
+ * (project > user > system > extra; first layer wins a name collision), keeping
151
+ * only entries whose directory satisfies `predicate` (e.g. holds `AGENT.md`).
152
+ */
153
+ function resolveSourceDirsByName(kind, cwd, excludeProject, predicate) {
154
+ const out = new Map();
155
+ for (const base of buildLayerBases(cwd, kind, { excludeProject })) {
156
+ if (!fs.existsSync(base.path))
157
+ continue;
158
+ let entries;
159
+ try {
160
+ entries = fs.readdirSync(base.path, { withFileTypes: true });
161
+ }
162
+ catch {
163
+ continue;
164
+ }
165
+ for (const entry of entries) {
166
+ if (!entry.isDirectory() || entry.name.startsWith('.'))
167
+ continue;
168
+ if (out.has(entry.name))
169
+ continue;
170
+ const dir = path.join(base.path, entry.name);
171
+ if (!predicate(dir))
172
+ continue;
173
+ out.set(entry.name, { layer: base.layer, path: dir, alias: base.alias });
174
+ }
175
+ }
176
+ return out;
177
+ }
124
178
  // ─── commands ─────────────────────────────────────────────────────────────────
125
179
  function diffCommands(agent, version, cwd, excludeProject = false) {
126
180
  const agentConfig = AGENTS[agent];
@@ -265,55 +319,6 @@ function diffCommands(agent, version, cwd, excludeProject = false) {
265
319
  return rows.sort((a, b) => a.name.localeCompare(b.name));
266
320
  }
267
321
  // ─── skills ───────────────────────────────────────────────────────────────────
268
- function dirsContentMatch(src, dst) {
269
- const ignore = new Set(['.DS_Store', '.git', '.gitignore', '.venv', '__pycache__', 'node_modules']);
270
- const srcEntries = (() => {
271
- try {
272
- return fs.readdirSync(src, { withFileTypes: true });
273
- }
274
- catch {
275
- return null;
276
- }
277
- })();
278
- const dstEntries = (() => {
279
- try {
280
- return fs.readdirSync(dst, { withFileTypes: true });
281
- }
282
- catch {
283
- return null;
284
- }
285
- })();
286
- if (!srcEntries || !dstEntries)
287
- return false;
288
- const filter = (es) => es.filter((e) => !e.isSymbolicLink() && !ignore.has(e.name)).sort((a, b) => a.name.localeCompare(b.name));
289
- const srcF = filter(srcEntries);
290
- const dstF = filter(dstEntries);
291
- if (srcF.length !== dstF.length)
292
- return false;
293
- for (let i = 0; i < srcF.length; i++) {
294
- if (srcF[i].name !== dstF[i].name)
295
- return false;
296
- const a = path.join(src, srcF[i].name);
297
- const b = path.join(dst, dstF[i].name);
298
- if (srcF[i].isDirectory()) {
299
- if (!dstF[i].isDirectory())
300
- return false;
301
- if (!dirsContentMatch(a, b))
302
- return false;
303
- }
304
- else if (srcF[i].isFile()) {
305
- if (!dstF[i].isFile())
306
- return false;
307
- const ac = readSafe(a);
308
- const bc = readSafe(b);
309
- if (ac == null || bc == null)
310
- return false;
311
- if (normalize(ac) !== normalize(bc))
312
- return false;
313
- }
314
- }
315
- return true;
316
- }
317
322
  function diffSkills(agent, version, cwd, excludeProject = false) {
318
323
  // Native ~/.agents/skills consumers (Gemini, …) read central skills directly.
319
324
  // The orchestrator DELETES their version-home skills dir (syncResourcesToVersion)
@@ -523,11 +528,18 @@ function diffRules(agent, version, cwd, excludeProject = false) {
523
528
  continue;
524
529
  }
525
530
  const expected = expectedRuleContent(agent, name, version, src.path);
526
- const actual = readSafe(homePath);
531
+ let actual = readSafe(homePath);
527
532
  if (expected == null || actual == null) {
528
533
  rows.push({ kind: 'rules', name, status: 'diff', source: src.layer, sourcePath: src.path, homePath });
529
534
  continue;
530
535
  }
536
+ // A non-@-import home compiled by `agents refresh-rules` carries a leading
537
+ // COMPILED_HEADER the raw preset composition does not. Strip it before the
538
+ // compare so a header-compiled home still reconciles — the header is
539
+ // agents-cli provenance, not source content (PHNX-3504).
540
+ if (name === 'AGENTS' && !supportsRulesImports(agent) && actual.startsWith(COMPILED_HEADER)) {
541
+ actual = actual.slice(COMPILED_HEADER.length);
542
+ }
531
543
  rows.push({
532
544
  kind: 'rules',
533
545
  name,
@@ -555,22 +567,175 @@ function diffRules(agent, version, cwd, excludeProject = false) {
555
567
  }
556
568
  return rows.sort((a, b) => a.name.localeCompare(b.name));
557
569
  }
558
- // ─── presence-only kinds ──────────────────────────────────────────────────────
559
- function diffPresenceOnly(kind, available, synced) {
570
+ // ─── mcp (content-aware) ────────────────────────────────────────────────────
571
+ function diffMcp(agent, version, cwd, available, synced) {
572
+ const versionHome = getVersionHomePath(agent, version);
573
+ const syncedSet = new Set(synced);
560
574
  const availableSet = new Set(available);
575
+ const homePath = getMcpConfigPathForHome(agent, versionHome);
576
+ // Resolve each available server's source def once (project trust off — this is
577
+ // a read-only diff, and the writer applies the same servers).
578
+ const sourceByName = new Map(getMcpServersByName(available, { cwd, enforceProjectTrust: false }).map((s) => [s.name, s]));
579
+ const rows = [];
580
+ for (const name of available) {
581
+ if (!syncedSet.has(name)) {
582
+ rows.push({ kind: 'mcp', name, status: 'missing', sourcePath: sourceByName.get(name)?.path });
583
+ continue;
584
+ }
585
+ const source = sourceByName.get(name);
586
+ const matches = source ? mcpServerMatches(agent, versionHome, name, source.config) : false;
587
+ rows.push({
588
+ kind: 'mcp',
589
+ name,
590
+ status: matches ? 'ok' : 'diff',
591
+ sourcePath: source?.path,
592
+ homePath,
593
+ });
594
+ }
595
+ for (const name of synced) {
596
+ if (!availableSet.has(name))
597
+ rows.push({ kind: 'mcp', name, status: 'extra', homePath });
598
+ }
599
+ return rows.sort((a, b) => a.name.localeCompare(b.name));
600
+ }
601
+ // ─── permissions (content-aware where the native format allows) ──────────────
602
+ function diffPermissions(agent, version, available, synced) {
603
+ const versionHome = getVersionHomePath(agent, version);
561
604
  const syncedSet = new Set(synced);
605
+ const availableSet = new Set(available);
606
+ const representable = PERMISSIONS_REPRESENTABLE.has(agent);
562
607
  const rows = [];
563
608
  for (const name of available) {
609
+ if (!syncedSet.has(name)) {
610
+ rows.push({ kind: 'permissions', name, status: 'missing' });
611
+ continue;
612
+ }
613
+ if (representable) {
614
+ const matches = permissionsGroupMatches(agent, versionHome, name);
615
+ rows.push({ kind: 'permissions', name, status: matches ? 'ok' : 'diff' });
616
+ }
617
+ else {
618
+ // Lossy TOML/flag harness — the on-disk format carries no faithful
619
+ // per-group rule provenance, so we can only attest presence. Say so
620
+ // explicitly rather than fake a verified `ok` (PHNX-3504).
621
+ rows.push({ kind: 'permissions', name, status: 'ok', detail: 'format cannot verify content' });
622
+ }
623
+ }
624
+ for (const name of synced) {
625
+ if (!availableSet.has(name))
626
+ rows.push({ kind: 'permissions', name, status: 'extra' });
627
+ }
628
+ return rows.sort((a, b) => a.name.localeCompare(b.name));
629
+ }
630
+ // ─── subagents (content-aware) ───────────────────────────────────────────────
631
+ function diffSubagents(agent, version, cwd, available, synced) {
632
+ const versionHome = getVersionHomePath(agent, version);
633
+ const syncedSet = new Set(synced);
634
+ const availableSet = new Set(available);
635
+ // Resolve sources across the SAME layers `available` was built from
636
+ // (getAvailableResources is project-inclusive), so every available name finds
637
+ // its source dir for the content compare rather than false-diffing.
638
+ const sourceByName = resolveSourceDirsByName('subagents', cwd, false, (dir) => fs.existsSync(path.join(dir, 'AGENT.md')));
639
+ const rows = [];
640
+ for (const name of available) {
641
+ const src = sourceByName.get(name);
642
+ if (!syncedSet.has(name)) {
643
+ rows.push({ kind: 'subagents', name, status: 'missing', source: src?.layer, sourcePath: src?.path });
644
+ continue;
645
+ }
646
+ const matches = src ? subagentContentMatches(agent, versionHome, name, src.path) : false;
564
647
  rows.push({
565
- kind,
648
+ kind: 'subagents',
566
649
  name,
567
- status: syncedSet.has(name) ? 'ok' : 'missing',
650
+ status: matches ? 'ok' : 'diff',
651
+ source: src?.layer,
652
+ sourcePath: src?.path,
568
653
  });
569
654
  }
570
655
  for (const name of synced) {
571
- if (availableSet.has(name))
656
+ if (!availableSet.has(name))
657
+ rows.push({ kind: 'subagents', name, status: 'extra' });
658
+ }
659
+ return rows.sort((a, b) => a.name.localeCompare(b.name));
660
+ }
661
+ // ─── workflows (content-aware) ───────────────────────────────────────────────
662
+ function diffWorkflows(agent, version, cwd, available, synced) {
663
+ const versionHome = getVersionHomePath(agent, version);
664
+ const syncedSet = new Set(synced);
665
+ const availableSet = new Set(available);
666
+ const rows = [];
667
+ for (const name of available) {
668
+ const sourcePath = resolveWorkflowRef(name, cwd);
669
+ if (!syncedSet.has(name)) {
670
+ rows.push({ kind: 'workflows', name, status: 'missing', sourcePath: sourcePath ?? undefined });
572
671
  continue;
573
- rows.push({ kind, name, status: 'extra' });
672
+ }
673
+ const matches = sourcePath ? workflowContentMatches(agent, versionHome, name, sourcePath) : false;
674
+ rows.push({
675
+ kind: 'workflows',
676
+ name,
677
+ status: matches ? 'ok' : 'diff',
678
+ sourcePath: sourcePath ?? undefined,
679
+ });
680
+ }
681
+ for (const name of synced) {
682
+ if (!availableSet.has(name))
683
+ rows.push({ kind: 'workflows', name, status: 'extra' });
684
+ }
685
+ return rows.sort((a, b) => a.name.localeCompare(b.name));
686
+ }
687
+ // ─── memory (knowledge facts — content-aware) ────────────────────────────────
688
+ /**
689
+ * Diff synced knowledge-fact memory (~/.agents/memory/*.md fanned into each
690
+ * capable home under `memoryTargetDir`). Distinct from the rules-preset list
691
+ * that overloads `AvailableResources.memory` — this reads the canonical facts
692
+ * (`listMemoryFacts`) and the home copies bounded by the `.agents-cli-memory.json`
693
+ * manifest, so an untracked native memory file the user authored is never
694
+ * mistaken for drift (PHNX-3504).
695
+ */
696
+ function diffMemory(agent, version, cwd) {
697
+ if (!supports(agent, 'memory', version).ok)
698
+ return [];
699
+ const versionHome = getVersionHomePath(agent, version);
700
+ const targetDir = path.join(versionHome, memoryTargetDir(agent));
701
+ const facts = listMemoryFacts(cwd);
702
+ // The manifest names exactly the fact files agents-cli wrote (plus 'MEMORY').
703
+ const managedManifestPath = path.join(targetDir, '.agents-cli-memory.json');
704
+ let managed = [];
705
+ try {
706
+ const raw = JSON.parse(fs.readFileSync(managedManifestPath, 'utf-8'));
707
+ if (Array.isArray(raw.facts))
708
+ managed = raw.facts.filter((f) => typeof f === 'string');
709
+ }
710
+ catch { /* no manifest → nothing managed yet */ }
711
+ const managedSet = new Set(managed);
712
+ const rows = [];
713
+ const factNames = new Set(facts.map((f) => f.name));
714
+ for (const fact of facts) {
715
+ const homePath = path.join(targetDir, `${fact.name}.md`);
716
+ if (!fs.existsSync(homePath)) {
717
+ rows.push({ kind: 'memory', name: fact.name, status: 'missing', source: fact.layer, sourcePath: fact.path });
718
+ continue;
719
+ }
720
+ const matches = filesContentMatch(fact.path, homePath);
721
+ rows.push({
722
+ kind: 'memory',
723
+ name: fact.name,
724
+ status: matches ? 'ok' : 'diff',
725
+ source: fact.layer,
726
+ sourcePath: fact.path,
727
+ homePath,
728
+ });
729
+ }
730
+ // A managed fact still on disk but no longer in the canonical set is an orphan
731
+ // the next sync would prune — surface it as extra. Never flag 'MEMORY' (the
732
+ // index, always regenerated) or an unmanaged native file (not ours to reconcile).
733
+ for (const name of managedSet) {
734
+ if (name === 'MEMORY' || factNames.has(name))
735
+ continue;
736
+ const homePath = path.join(targetDir, `${name}.md`);
737
+ if (fs.existsSync(homePath))
738
+ rows.push({ kind: 'memory', name, status: 'extra', homePath });
574
739
  }
575
740
  return rows.sort((a, b) => a.name.localeCompare(b.name));
576
741
  }
@@ -683,12 +848,6 @@ function diffPlugins(agent, version, cwd) {
683
848
  }
684
849
  return rows.sort((a, b) => a.name.localeCompare(b.name));
685
850
  }
686
- function diffPromptcuts() {
687
- const sourcePath = getEffectivePromptcutsPath();
688
- if (!fs.existsSync(sourcePath))
689
- return [];
690
- return [{ kind: 'promptcuts', name: 'promptcuts.yaml', status: 'ok', sourcePath }];
691
- }
692
851
  export function diffVersionResources(agent, version, options = {}) {
693
852
  const rawCwd = options.cwd ?? process.cwd();
694
853
  const excludeProject = options.excludeProject ?? false;
@@ -709,7 +868,8 @@ export function diffVersionResources(agent, version, options = {}) {
709
868
  permissions: [],
710
869
  subagents: [],
711
870
  plugins: [],
712
- promptcuts: [],
871
+ workflows: [],
872
+ memory: [],
713
873
  };
714
874
  if (requested.has('commands'))
715
875
  empty.commands = diffCommands(agent, version, cwd, excludeProject);
@@ -725,20 +885,26 @@ export function diffVersionResources(agent, version, options = {}) {
725
885
  if (requested.has('rules'))
726
886
  empty.rules = diffRules(agent, version, cwd, excludeProject);
727
887
  if (requested.has('mcp'))
728
- empty.mcp = diffPresenceOnly('mcp', available.mcp, synced.mcp);
729
- if (requested.has('permissions'))
730
- empty.permissions = diffPresenceOnly('permissions', available.permissions, synced.permissions);
888
+ empty.mcp = diffMcp(agent, version, cwd, available.mcp, synced.mcp);
889
+ if (requested.has('permissions')) {
890
+ empty.permissions = diffPermissions(agent, version, supports(agent, 'allowlist', version).ok ? available.permissions : [], synced.permissions);
891
+ }
731
892
  // Subagents are version-gated (e.g. kimi >= 0.29.0). A version below the floor
732
893
  // is never written any subagent by the sync writer, so counting the source
733
894
  // ones as "missing" is phantom drift no sync can clear (PHNX-3186). Zero the
734
895
  // available set when unsupported; a stale installed copy still surfaces `extra`.
735
896
  if (requested.has('subagents')) {
736
- empty.subagents = diffPresenceOnly('subagents', supports(agent, 'subagents', version).ok ? available.subagents : [], synced.subagents);
897
+ empty.subagents = diffSubagents(agent, version, cwd, supports(agent, 'subagents', version).ok ? available.subagents : [], synced.subagents);
737
898
  }
738
899
  if (requested.has('plugins'))
739
900
  empty.plugins = diffPlugins(agent, version, cwd);
740
- if (requested.has('promptcuts'))
741
- empty.promptcuts = diffPromptcuts();
901
+ // Workflows are version-gated too (e.g. grok >= 0.2.111); zero the available
902
+ // set below the floor so a source workflow is not phantom-missing drift.
903
+ if (requested.has('workflows')) {
904
+ empty.workflows = diffWorkflows(agent, version, cwd, supports(agent, 'workflows', version).ok ? available.workflows : [], synced.workflows);
905
+ }
906
+ if (requested.has('memory'))
907
+ empty.memory = diffMemory(agent, version, cwd);
742
908
  let ok = 0, diff = 0, missing = 0, extra = 0;
743
909
  for (const list of Object.values(empty)) {
744
910
  for (const r of list) {
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import type { ActorKind } from '../actor.js';
15
15
  export type EventLevel = 'audit' | 'warn' | 'info' | 'debug';
16
- export type EventType = 'agent.run.start' | 'agent.run.end' | 'agent.spawn.start' | 'agent.spawn.end' | 'run.dispatched' | 'daemon.start' | 'daemon.stop' | 'daemon.error' | 'daemon.info' | 'routine.start' | 'routine.end' | 'watchdog.action' | 'version.install' | 'version.switch' | 'version.remove' | 'skill.install' | 'skill.remove' | 'browser.launch' | 'browser.close' | 'browser.navigate' | 'browser.screenshot' | 'computer.action' | 'secrets.get' | 'secrets.unlocked' | 'secrets.create' | 'secrets.import' | 'secrets.export' | 'secrets.view' | 'secrets.lease-denied' | 'secrets.lease-expire' | 'secrets.set' | 'secrets.delete' | 'secrets.rename' | 'cloud.dispatch' | 'cloud.complete' | 'cloud.cancel' | 'cloud.message' | 'teams.create' | 'teams.add' | 'teams.start' | 'teams.complete' | 'teams.disband' | 'hook.fire' | 'hook.complete' | 'hook.error' | 'mcp.add' | 'mcp.remove' | 'mcp.register' | 'resource.sync' | 'rotation.resolved' | 'command.start' | 'command.end' | 'perf.timing' | 'session.start' | 'session.end' | 'webhook.received' | 'webhook.authorized' | 'webhook.rejected' | 'webhook.matched' | 'webhook.fired' | 'webhook.failed' | 'webhook.handler.start' | 'webhook.handler.end' | 'plan.created' | 'pr.opened' | 'pr.merged' | 'worktree.created' | 'worktree.removed' | 'commit.created' | 'pushed' | 'subagent.spawned' | 'artifact.created' | 'task.completed' | 'checklist.created' | 'status.posted' | 'file.edited' | 'factory.command' | 'factory.action' | 'factory.uri' | 'factory.launch' | 'friction' | 'error' | 'warn' | 'info' | 'debug';
16
+ export type EventType = 'agent.run.start' | 'agent.run.end' | 'agent.spawn.start' | 'agent.spawn.end' | 'run.dispatched' | 'daemon.start' | 'daemon.stop' | 'daemon.error' | 'daemon.info' | 'routine.start' | 'routine.end' | 'watchdog.action' | 'version.install' | 'version.switch' | 'version.remove' | 'skill.install' | 'skill.remove' | 'browser.launch' | 'browser.close' | 'browser.navigate' | 'browser.screenshot' | 'computer.action' | 'secrets.get' | 'secrets.unlocked' | 'secrets.create' | 'secrets.import' | 'secrets.export' | 'secrets.view' | 'secrets.lease-denied' | 'secrets.lease-expire' | 'secrets.set' | 'secrets.delete' | 'secrets.rename' | 'cloud.dispatch' | 'cloud.complete' | 'cloud.cancel' | 'cloud.message' | 'teams.create' | 'teams.add' | 'teams.start' | 'teams.complete' | 'teams.disband' | 'hook.fire' | 'hook.complete' | 'hook.error' | 'mcp.add' | 'mcp.remove' | 'mcp.register' | 'resource.sync' | 'rotation.resolved' | 'rotation.unresolved' | 'command.start' | 'command.end' | 'perf.timing' | 'session.start' | 'session.end' | 'webhook.received' | 'webhook.authorized' | 'webhook.rejected' | 'webhook.matched' | 'webhook.fired' | 'webhook.failed' | 'webhook.handler.start' | 'webhook.handler.end' | 'plan.created' | 'pr.opened' | 'pr.merged' | 'worktree.created' | 'worktree.removed' | 'commit.created' | 'pushed' | 'subagent.spawned' | 'artifact.created' | 'task.completed' | 'checklist.created' | 'status.posted' | 'file.edited' | 'factory.command' | 'factory.action' | 'factory.uri' | 'factory.launch' | 'friction' | 'error' | 'warn' | 'info' | 'debug';
17
17
  /** Every known event kind. Derived from {@link EVENT_TYPE_TABLE}, never hand-listed. */
18
18
  export declare const EVENT_TYPES: readonly EventType[];
19
19
  /** Runtime guard for an event kind arriving from outside this process. */
@@ -19,25 +19,33 @@ import { gzipSync, gunzipSync } from 'node:zlib';
19
19
  import { ensureLockTarget, withFileLock } from '../fs-atomic.js';
20
20
  import { getUserAgentsDir } from '../state.js';
21
21
  import { stampProvenance, resetEventProvenanceForTest } from '../event-provenance.js';
22
- /** Lazy perf warehouse write — avoids a hard cycle at module load. */
22
+ import { recordSample } from '../perf/spool.js';
23
+ /**
24
+ * Perf warehouse write. SYNCHRONOUS by design (PHNX-3497): `recordSample` is an
25
+ * `appendFileSync` that never opens SQLite (see perf/spool.ts), so calling it
26
+ * directly costs one file append and lands the row before this function returns.
27
+ * It MUST NOT be deferred behind a `void import().then(...)`: the `agent.run`
28
+ * timer ends at the very tail of `agents run`, and the CLI process exits before
29
+ * any deferred microtask runs — so a fire-and-forget spool write was silently
30
+ * lost on every foreground run, leaving the PHNX-3468 startup-phase surface with
31
+ * no data. (Hooks were unaffected only because their shim appends the spool line
32
+ * synchronously in bash.)
33
+ */
23
34
  function recordPerfTiming(payload) {
24
35
  try {
25
36
  const phases = payload.phases && Object.keys(payload.phases).length > 0 ? payload.phases : undefined;
26
37
  const metaJson = phases ? JSON.stringify({ phases }) : undefined;
27
- // Dynamic import keeps events.ts free of a load-time dependency on perf/db.
28
- void import('../perf/spool.js').then(({ recordSample }) => {
29
- recordSample({
30
- kind: 'perf.timing',
31
- label: payload.label,
32
- durationMs: payload.durationMs,
33
- status: payload.status,
34
- agent: payload.agent,
35
- agentVersion: payload.version,
36
- sessionId: payload.sessionId,
37
- cwd: payload.cwd,
38
- metaJson,
39
- });
40
- }).catch(() => { });
38
+ recordSample({
39
+ kind: 'perf.timing',
40
+ label: payload.label,
41
+ durationMs: payload.durationMs,
42
+ status: payload.status,
43
+ agent: payload.agent,
44
+ agentVersion: payload.version,
45
+ sessionId: payload.sessionId,
46
+ cwd: payload.cwd,
47
+ metaJson,
48
+ });
41
49
  }
42
50
  catch {
43
51
  // fail soft
@@ -127,6 +135,7 @@ const EVENT_TYPE_TABLE = {
127
135
  'mcp.add': true, 'mcp.remove': true, 'mcp.register': true,
128
136
  'resource.sync': true,
129
137
  'rotation.resolved': true,
138
+ 'rotation.unresolved': true,
130
139
  'command.start': true, 'command.end': true,
131
140
  'perf.timing': true,
132
141
  'session.start': true, 'session.end': true,
@@ -156,7 +165,7 @@ const AUDIT_EVENTS = new Set([
156
165
  'version.install', 'version.switch', 'version.remove',
157
166
  'skill.install', 'skill.remove',
158
167
  'mcp.add', 'mcp.remove', 'mcp.register',
159
- 'rotation.resolved',
168
+ 'rotation.resolved', 'rotation.unresolved',
160
169
  'session.start', 'session.end',
161
170
  // Daemon lifecycle (always-on process start/stop/error is audit-relevant).
162
171
  // browser.* / computer.action stay info — they are already on the event stream;
@@ -36,8 +36,8 @@
36
36
  import { spawnSync } from 'child_process';
37
37
  import { isOwnerAlias, readOwnerDest, resolveSendEnvelope, deliverEnvelope } from './channels/send.js';
38
38
  import { lookupTransport } from './channels/resolve.js';
39
- import { forwardOwnerNotifyToPeer } from './channels/owner-forward.js';
40
39
  import { registerBuiltinProviders } from './channels/providers/index.js';
40
+ import { sendToOwner } from './notify.js';
41
41
  const LEVEL_RANK = { milestone: 0, important: 1 };
42
42
  /** Parse a `--level` value; anything unrecognized is a usage error, not a default. */
43
43
  export function parseFeedPostLevel(raw) {
@@ -462,6 +462,10 @@ async function runChannelSink(sink, meta) {
462
462
  // for a name that is, in fact, registered.
463
463
  registerBuiltinProviders();
464
464
  const owner = isOwnerAlias(sink.channel);
465
+ if (owner) {
466
+ const result = await sendToOwner(sink.text ?? '', { meta });
467
+ return { name, ok: result.ok, ...(result.error ? { error: result.error } : {}) };
468
+ }
465
469
  const resolved = resolveSendEnvelope({
466
470
  text: sink.text ?? '',
467
471
  channel: owner ? undefined : sink.channel,
@@ -476,17 +480,6 @@ async function runChannelSink(sink, meta) {
476
480
  const result = await deliverEnvelope(resolved.envelope, meta);
477
481
  if (result.ok)
478
482
  return { name, ok: true };
479
- // The owner sink failed locally. When this box structurally cannot reach the
480
- // owner (the rush-backed channel is macOS-only, so a headless Linux worker can
481
- // never ring the phone — PHNX-3303), hand the delivery to a capable fleet peer
482
- // over SSH rather than stranding the important post. Only the `owner` alias
483
- // forwards: it resolves the peer's own fleet-synced owner destination, so a
484
- // non-owner channel sink with an explicit recipient stays local.
485
- if (owner) {
486
- const forwarded = await forwardOwnerNotifyToPeer(sink.text ?? '', resolved.envelope.channel, meta);
487
- if (forwarded?.ok)
488
- return { name, ok: true };
489
- }
490
483
  return { name, ok: false, error: result.error };
491
484
  }
492
485
  /**
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The delegate behind the `gh` PATH shim (`agents __gh --real-gh <path> -- <argv>`).
3
+ *
4
+ * Agents are statically trained to type `gh pr checks …`; the shim intercepts that
5
+ * habit and routes it here so the rate-limit-prone read runs over REST instead of
6
+ * GraphQL — no new command for an agent to remember. This is the Tier-1 scope:
7
+ * only `gh pr checks` is handled; every other invocation execs the real gh
8
+ * byte-for-byte (the shim already passes non-`pr checks` verbs straight to real gh,
9
+ * and this file passes through defensively for anything it cannot cleanly serve).
10
+ *
11
+ * Two switch modes (see the PHNX-3501 plan):
12
+ * - `--watch` → EAGER REST: own the poll loop, re-anchored to the live head SHA
13
+ * each tick, so a superseded run's red can never be reported (PHNX-3042).
14
+ * - one-shot → LAZY: run real gh first; only translate to REST when it fails with
15
+ * the exact GraphQL rate-limit signal. An over-budget GraphQL call is rejected
16
+ * at 0 points, so this is nearly free and never reproduces gh's happy path.
17
+ *
18
+ * Fail-open is the rule: anything not cleanly resolvable (no PR number/URL, an
19
+ * unmappable request) execs real gh rather than returning wrong data.
20
+ */
21
+ import { type RollupItem } from './rest.js';
22
+ interface Parsed {
23
+ realGh: string;
24
+ ghArgs: string[];
25
+ }
26
+ /** Split `--real-gh <path> -- <gh argv>`; defaults realGh to bare `gh`. */
27
+ export declare function parseDelegateArgs(argv: string[]): Parsed;
28
+ export interface Target {
29
+ repo: string;
30
+ number: number;
31
+ }
32
+ /** owner/repo from a git remote URL, or null. */
33
+ export declare function repoFromRemote(remoteUrl: string): string | null;
34
+ /**
35
+ * Resolve `{repo, number}` from the `gh pr checks` argv + cwd, over git/REST only.
36
+ * Returns null (→ caller passes through to real gh) when it can't be resolved
37
+ * cleanly, e.g. no number and no open PR for the current branch.
38
+ */
39
+ export declare function resolveTarget(ghArgs: string[], cwd: string, realGh: string): Promise<Target | null>;
40
+ /** Render one rollup as `gh pr checks`-ish lines (or JSON when asked). */
41
+ export declare function renderRollup(input: RollupItem[], json: boolean): string;
42
+ /**
43
+ * True once CI has settled for this SHA.
44
+ *
45
+ * Check-suites only disambiguate an EMPTY rollup: no checks + no pending suites is
46
+ * "genuinely none" (settled); no checks + a pending suite is "not registered yet"
47
+ * (wait). Once real checks exist we decide on THEM alone and ignore suites — some
48
+ * App integrations (claude/cursor reviewers) register a suite that stays `queued`
49
+ * forever and never posts a run, exactly what `gh pr checks` also ignores.
50
+ */
51
+ export declare function isSettled(rollup: RollupItem[], pendingSuites: number): boolean;
52
+ /** Eager REST watch: poll to a terminal state, re-anchored to the live head SHA. */
53
+ export declare function watchChecks(target: Target, json: boolean, realGh: string): Promise<number>;
54
+ /** Lazy one-shot: real gh first; translate to REST only on the exact rate-limit signal. */
55
+ export declare function checksOnce(target: Target | null, json: boolean, realGh: string, ghArgs: string[]): Promise<number>;
56
+ /** Entry point for the `__gh` early branch in index.ts. */
57
+ export declare function runGhOverload(argv: string[], cwd?: string): Promise<number>;
58
+ export {};