@ours.network/fleet 0.18.0-nightly.5 → 0.18.0

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 (65) hide show
  1. package/README.md +101 -25
  2. package/dist/application/fleet-query-service.js +12 -0
  3. package/dist/application/role-creation-service.js +3 -1
  4. package/dist/application/types.d.ts +11 -0
  5. package/dist/briefing.js +9 -2
  6. package/dist/build-info.json +7 -6
  7. package/dist/capabilities.d.ts +3 -1
  8. package/dist/capabilities.js +3 -0
  9. package/dist/cli.js +83 -7
  10. package/dist/config.d.ts +11 -3
  11. package/dist/config.js +40 -15
  12. package/dist/creation.d.ts +14 -15
  13. package/dist/creation.js +19 -13
  14. package/dist/docs.d.ts +1 -1
  15. package/dist/docs.js +113 -27
  16. package/dist/doctor.d.ts +1 -5
  17. package/dist/doctor.js +11 -18
  18. package/dist/fleet-proxy.d.ts +5 -0
  19. package/dist/harness/acp-agent.js +11 -6
  20. package/dist/harness/claude-code.js +204 -11
  21. package/dist/harness/codex.d.ts +4 -1
  22. package/dist/harness/codex.js +74 -12
  23. package/dist/harness/types.d.ts +54 -4
  24. package/dist/harness-plugins.d.ts +48 -0
  25. package/dist/harness-plugins.js +309 -0
  26. package/dist/index.d.ts +2 -0
  27. package/dist/index.js +1 -0
  28. package/dist/loops/manager.d.ts +30 -1
  29. package/dist/loops/manager.js +69 -6
  30. package/dist/loops/state.d.ts +18 -0
  31. package/dist/loops/state.js +4 -0
  32. package/dist/model-env.d.ts +71 -0
  33. package/dist/model-env.js +106 -0
  34. package/dist/monitor.js +1 -1
  35. package/dist/ops.js +1 -1
  36. package/dist/owner-channel/attachments.d.ts +2 -25
  37. package/dist/owner-channel/attachments.js +5 -61
  38. package/dist/owner-channel/channel.d.ts +28 -17
  39. package/dist/owner-channel/channel.js +249 -158
  40. package/dist/owner-channel/mcp.d.ts +24 -0
  41. package/dist/owner-channel/mcp.js +145 -0
  42. package/dist/owner-channel/notices.d.ts +7 -0
  43. package/dist/owner-channel/notices.js +9 -0
  44. package/dist/resolved-plan.js +1 -0
  45. package/dist/runner.d.ts +48 -0
  46. package/dist/runner.js +237 -85
  47. package/dist/session/acp.d.ts +104 -0
  48. package/dist/session/acp.js +213 -10
  49. package/dist/session/activity.d.ts +31 -0
  50. package/dist/session/activity.js +48 -0
  51. package/dist/session/conversation-normalizer.d.ts +6 -0
  52. package/dist/session/conversation-normalizer.js +153 -10
  53. package/dist/session/conversation-types.d.ts +23 -4
  54. package/dist/session/types.d.ts +35 -0
  55. package/dist/spawn.js +29 -17
  56. package/dist/supervisor/systemd.js +2 -29
  57. package/dist/watchdog/briefing.js +7 -0
  58. package/dist/web-app/assets/{TerminalView-BAVk1Bot.js → TerminalView-C_G1ID2P.js} +1 -1
  59. package/dist/web-app/assets/{index-C3S-xFRU.js → index-BCBK78hw.js} +5 -5
  60. package/dist/web-app/index.html +1 -1
  61. package/dist/worklog.d.ts +7 -1
  62. package/dist/worklog.js +191 -39
  63. package/package.json +1 -3
  64. package/dist/owner-channel/ours-client.d.ts +0 -141
  65. package/dist/owner-channel/ours-client.js +0 -225
@@ -1,3 +1,4 @@
1
+ import type { McpServer } from '@agentclientprotocol/sdk';
1
2
  import type { CommonPermissions, FleetPermissionMode, ResolvedRole } from '../config.js';
2
3
  export interface PrereqCheck {
3
4
  name: string;
@@ -21,14 +22,39 @@ export interface SessionPrep {
21
22
  env: Record<string, string>;
22
23
  /** Optional launcher selected after runtime prerequisite probing. */
23
24
  command?: string;
25
+ /**
26
+ * The settings overlay prepareSession wrote, if it wrote one.
27
+ *
28
+ * The tmux launch delivers this as `--settings <path>` in `argv`; an ACP agent
29
+ * takes no flags, so it needs the PATH rather than the flag. Recorded here so
30
+ * the two deliveries read one value instead of each re-deriving the filename.
31
+ */
32
+ settingsOverlay?: string;
33
+ /**
34
+ * The MCP config file prepareSession wrote for `harness_options.mcp_servers`,
35
+ * if the role declared any. Same reason as `settingsOverlay`: the tmux launch
36
+ * passes the file, the ACP launch has to send the servers themselves.
37
+ */
38
+ mcpConfigFile?: string;
24
39
  }
40
+ /**
41
+ * One MCP server as ACP's `session/new` declares it.
42
+ *
43
+ * ⚠ THE PROTOCOL'S OWN TYPE, DELIBERATELY NOT A LOCAL RESTATEMENT. `mcpServers`
44
+ * goes onto the wire unchanged, so a hand-written near-copy would compile while
45
+ * being subtly wrong — `env` and `headers` are REQUIRED arrays, and the stdio
46
+ * variant is the one with no `type` field at all. Aliasing it also keeps
47
+ * `session/new`'s response type inferable, which a structural stand-in silently
48
+ * broke (every field of the result degraded to `unknown`).
49
+ */
50
+ export type AcpMcpServer = McpServer;
25
51
  export interface Launch {
26
52
  argv: string[];
27
53
  env: Record<string, string>;
28
54
  }
29
- export interface AcpLaunch {
30
- argv: string[];
31
- env: Record<string, string>;
55
+ export interface AcpLaunch extends Launch {
56
+ /** Metadata vocabulary authenticated by the exact ACP artifact in argv. */
57
+ permissionMetadataSource?: 'codex-acp';
32
58
  }
33
59
  /**
34
60
  * The result of expressing neutral `permissions:` in a harness's own terms.
@@ -67,6 +93,7 @@ export interface BriefingVocab {
67
93
  currentIdentityTool: string;
68
94
  sendTool: string;
69
95
  getMessagesTool: string;
96
+ watchCommand(identity: string): string;
70
97
  monitorInstruction(identity: string, role?: ResolvedRole): string;
71
98
  /** Wake-source wording for a role whose monitor is supervisor-owned (monitor.mode=fleet). */
72
99
  supervisedWakeNote(identity: string, role?: ResolvedRole): string;
@@ -95,7 +122,13 @@ export interface HarnessAdapter {
95
122
  id: string;
96
123
  supportsResume: boolean;
97
124
  checkPrereqs(): Promise<PrereqReport>;
98
- validateOptions(opts: unknown): ValidationError[];
125
+ /**
126
+ * `role` is the SESSION-AWARE half: some harness options can only be honoured
127
+ * on some session types, and an option that is silently dropped is worse than
128
+ * one that is refused. Optional so an adapter that has nothing session-specific
129
+ * to say keeps its one-argument implementation.
130
+ */
131
+ validateOptions(opts: unknown, role?: ResolvedRole): ValidationError[];
99
132
  prepareSession(role: ResolvedRole, dirs: RoleDirs): Promise<SessionPrep>;
100
133
  buildLaunch(role: ResolvedRole, mode: 'fresh' | 'resume', s: SessionState, prep: SessionPrep): Launch;
101
134
  buildAcpLaunch?(role: ResolvedRole, prep: SessionPrep): AcpLaunch;
@@ -105,6 +138,23 @@ export interface HarnessAdapter {
105
138
  * agent's default. Omit for a harness whose ACP agent has no modes.
106
139
  */
107
140
  acpPermissionModeId?(role: ResolvedRole): string | undefined;
141
+ /**
142
+ * The MCP servers this role declares, for the `mcpServers` array of ACP's
143
+ * `session/new` / `resume` / `load`. Empty (or omitted) leaves the agent's own
144
+ * configuration alone, which is what fleet has always sent.
145
+ */
146
+ acpMcpServers?(role: ResolvedRole): AcpMcpServer[];
147
+ /**
148
+ * Agent-specific `_meta` for `session/new` — how a capability the CLI takes as
149
+ * a flag reaches an ACP agent that accepts no flags.
150
+ *
151
+ * ⚠ THIS IS A PER-AGENT VOCABULARY, NOT PROTOCOL. `_meta` is free-form in ACP,
152
+ * so what an adapter puts here is only honoured by the agent it was written
153
+ * for. An adapter must therefore return nothing for an ACP command it did not
154
+ * choose, and the options that depend on it must be refused at validation for
155
+ * such a role rather than sent and silently ignored.
156
+ */
157
+ acpSessionMeta?(role: ResolvedRole, prep: SessionPrep): Record<string, unknown> | undefined;
108
158
  /** Effective portable policy and harness-native approval mode after native overrides win. */
109
159
  effectivePermissionMode?(role: ResolvedRole): {
110
160
  fleetMode: FleetPermissionMode;
@@ -0,0 +1,48 @@
1
+ import { type Exec } from './exec.js';
2
+ export declare const HARNESS_PLUGIN_IDS: readonly ["codex", "claude-code"];
3
+ export type HarnessPluginId = (typeof HARNESS_PLUGIN_IDS)[number];
4
+ export type HarnessPluginChannel = 'stable' | 'nightly';
5
+ export interface HarnessPluginConfig {
6
+ plugin_channel: HarnessPluginChannel;
7
+ }
8
+ export type HarnessPluginConfigs = Record<HarnessPluginId, HarnessPluginConfig>;
9
+ export interface HarnessPluginLock {
10
+ schemaVersion: 1;
11
+ harness: HarnessPluginId;
12
+ channel: HarnessPluginChannel;
13
+ distTag: 'latest' | 'nightly';
14
+ package: string;
15
+ version: string;
16
+ registry: 'https://registry.npmjs.org';
17
+ resolvedAt: string;
18
+ }
19
+ export interface HarnessPluginInstallResult {
20
+ lock: HarnessPluginLock;
21
+ lockPath: string;
22
+ marketplacePath: string;
23
+ resolved: boolean;
24
+ }
25
+ export declare function resolveHarnessPluginConfigs(raw: unknown, file?: string): HarnessPluginConfigs;
26
+ export declare const harnessPluginRoot: (harness: HarnessPluginId) => string;
27
+ export declare const harnessPluginLockPath: (harness: HarnessPluginId) => string;
28
+ export declare const harnessPluginMarketplaceRoot: (harness: HarnessPluginId) => string;
29
+ export declare const harnessPluginMarketplacePath: (harness: HarnessPluginId) => string;
30
+ export declare function readHarnessPluginLock(harness: HarnessPluginId): HarnessPluginLock | undefined;
31
+ /**
32
+ * Re-materialize the generated marketplace from the persisted exact lock.
33
+ * This is the ONLY ordinary startup/reconciliation path: it performs no exec,
34
+ * no network call, and cannot observe a moving npm dist-tag.
35
+ */
36
+ export declare function restoreLockedHarnessMarketplace(harness: HarnessPluginId, expectedChannel?: HarnessPluginChannel): HarnessPluginLock | undefined;
37
+ /**
38
+ * Explicit install/update transaction.
39
+ *
40
+ * install reuses an existing same-channel lock (deterministic repair/reinstall);
41
+ * update always resolves the requested channel once and advances the lock.
42
+ */
43
+ export declare function installHarnessPlugin(harness: HarnessPluginId, channel: HarnessPluginChannel, options?: {
44
+ update?: boolean;
45
+ exec?: Exec;
46
+ now?: () => Date;
47
+ }): Promise<HarnessPluginInstallResult>;
48
+ export declare function selectedHarnessPluginIds(values: string[]): HarnessPluginId[];
@@ -0,0 +1,309 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join, resolve } from 'node:path';
3
+ import { replaceFileAtomically, withFileLock } from './atomic-file.js';
4
+ import { realExec } from './exec.js';
5
+ import { stateRoot } from './paths.js';
6
+ export const HARNESS_PLUGIN_IDS = ['codex', 'claude-code'];
7
+ const REGISTRY = 'https://registry.npmjs.org';
8
+ const SPECS = {
9
+ codex: {
10
+ package: '@ours.network/codex',
11
+ marketplaceName: 'ours-fleet-codex-lock',
12
+ marketplaceManifest: '.agents/plugins/marketplace.json',
13
+ executable: 'codex',
14
+ },
15
+ 'claude-code': {
16
+ package: '@ours.network/claude-code',
17
+ marketplaceName: 'ours-fleet-claude-lock',
18
+ marketplaceManifest: '.claude-plugin/marketplace.json',
19
+ executable: 'claude',
20
+ },
21
+ };
22
+ const EXACT_SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
23
+ const isRecord = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
24
+ export function resolveHarnessPluginConfigs(raw, file = 'fleet.yaml') {
25
+ const resolved = {
26
+ codex: { plugin_channel: 'stable' },
27
+ 'claude-code': { plugin_channel: 'stable' },
28
+ };
29
+ if (raw === undefined)
30
+ return resolved;
31
+ if (!isRecord(raw))
32
+ throw new Error(`${file}: harnesses must be a map`);
33
+ const unknown = Object.keys(raw).filter(key => !HARNESS_PLUGIN_IDS.includes(key));
34
+ if (unknown.length)
35
+ throw new Error(`${file}: harnesses has unknown harness(es) ${unknown.join(', ')}; allowed: ${HARNESS_PLUGIN_IDS.join(', ')}`);
36
+ for (const harness of HARNESS_PLUGIN_IDS) {
37
+ const value = raw[harness];
38
+ if (value === undefined)
39
+ continue;
40
+ if (!isRecord(value))
41
+ throw new Error(`${file}: harnesses.${harness} must be a map`);
42
+ const bad = Object.keys(value).filter(key => key !== 'plugin_channel');
43
+ if (bad.length)
44
+ throw new Error(`${file}: harnesses.${harness} has unknown key(s) ${bad.join(', ')}; allowed: plugin_channel`);
45
+ const channel = value.plugin_channel;
46
+ if (channel !== 'stable' && channel !== 'nightly')
47
+ throw new Error(`${file}: harnesses.${harness}.plugin_channel must be one of: stable, nightly`);
48
+ resolved[harness] = { plugin_channel: channel };
49
+ }
50
+ return resolved;
51
+ }
52
+ export const harnessPluginRoot = (harness) => join(stateRoot(), 'harness-plugins', harness);
53
+ export const harnessPluginLockPath = (harness) => join(harnessPluginRoot(harness), 'plugin-lock.json');
54
+ export const harnessPluginMarketplaceRoot = (harness) => join(harnessPluginRoot(harness), 'marketplace');
55
+ export const harnessPluginMarketplacePath = (harness) => join(harnessPluginMarketplaceRoot(harness), SPECS[harness].marketplaceManifest);
56
+ function validateLock(value, harness, path) {
57
+ const spec = SPECS[harness];
58
+ const fail = (detail) => {
59
+ throw new Error(`invalid harness plugin lock ${path}: ${detail}; run \`ours-fleet plugins update ${harness}\``);
60
+ };
61
+ if (!isRecord(value))
62
+ return fail('expected a JSON object');
63
+ if (value.schemaVersion !== 1)
64
+ fail('unsupported schemaVersion');
65
+ if (value.harness !== harness)
66
+ fail(`harness must be '${harness}'`);
67
+ if (value.channel !== 'stable' && value.channel !== 'nightly')
68
+ fail('channel must be stable or nightly');
69
+ const expectedTag = value.channel === 'stable' ? 'latest' : 'nightly';
70
+ if (value.distTag !== expectedTag)
71
+ fail(`distTag must be '${expectedTag}' for channel '${value.channel}'`);
72
+ if (value.package !== spec.package)
73
+ fail(`package must be '${spec.package}'`);
74
+ if (typeof value.version !== 'string' || !EXACT_SEMVER.test(value.version))
75
+ return fail('version must be an exact semver');
76
+ const version = value.version;
77
+ if (value.channel === 'stable' && version.includes('-'))
78
+ fail('stable channel resolved to a prerelease');
79
+ if (value.channel === 'nightly' && !version.includes('-nightly.'))
80
+ fail('nightly channel did not resolve to a -nightly.N prerelease');
81
+ if (value.registry !== REGISTRY)
82
+ fail(`registry must be '${REGISTRY}'`);
83
+ if (typeof value.resolvedAt !== 'string' || !Number.isFinite(Date.parse(value.resolvedAt)))
84
+ fail('resolvedAt must be an ISO timestamp');
85
+ return value;
86
+ }
87
+ export function readHarnessPluginLock(harness) {
88
+ const path = harnessPluginLockPath(harness);
89
+ if (!existsSync(path))
90
+ return undefined;
91
+ let parsed;
92
+ try {
93
+ parsed = JSON.parse(readFileSync(path, 'utf8'));
94
+ }
95
+ catch (error) {
96
+ throw new Error(`invalid harness plugin lock ${path}: ${error.message}; `
97
+ + `run \`ours-fleet plugins update ${harness}\``);
98
+ }
99
+ return validateLock(parsed, harness, path);
100
+ }
101
+ function marketplaceDocument(lock) {
102
+ const spec = SPECS[lock.harness];
103
+ const source = lock.harness === 'codex'
104
+ ? { source: 'npm', package: lock.package, version: lock.version, registry: lock.registry }
105
+ : { source: 'npm', package: lock.package, version: lock.version };
106
+ if (lock.harness === 'codex') {
107
+ return {
108
+ name: spec.marketplaceName,
109
+ interface: { displayName: `ours.network (${lock.channel}, locked ${lock.version})` },
110
+ plugins: [{
111
+ name: 'ours', source,
112
+ policy: { installation: 'AVAILABLE', authentication: 'ON_INSTALL' },
113
+ category: 'Productivity',
114
+ }],
115
+ };
116
+ }
117
+ return {
118
+ $schema: 'https://json.schemastore.org/claude-code-marketplace.json',
119
+ name: spec.marketplaceName,
120
+ owner: { name: 'Adapt Framework Solutions Ltd', url: 'https://ours.network' },
121
+ description: `Generated by ours-fleet; @ours.network/claude-code is locked to ${lock.version}.`,
122
+ plugins: [{
123
+ name: 'ours', source,
124
+ description: 'Secure ours.network messaging for Claude Code.',
125
+ }],
126
+ };
127
+ }
128
+ function writeMarketplace(lock) {
129
+ const path = harnessPluginMarketplacePath(lock.harness);
130
+ replaceFileAtomically(path, `${JSON.stringify(marketplaceDocument(lock), null, 2)}\n`, 0o644);
131
+ return path;
132
+ }
133
+ /**
134
+ * Re-materialize the generated marketplace from the persisted exact lock.
135
+ * This is the ONLY ordinary startup/reconciliation path: it performs no exec,
136
+ * no network call, and cannot observe a moving npm dist-tag.
137
+ */
138
+ export function restoreLockedHarnessMarketplace(harness, expectedChannel) {
139
+ const lock = readHarnessPluginLock(harness);
140
+ if (!lock) {
141
+ if (expectedChannel === 'nightly')
142
+ throw new Error(`harness '${harness}' requests nightly but has no exact lock; `
143
+ + `run \`ours-fleet plugins install ${harness}\``);
144
+ return undefined;
145
+ }
146
+ if (expectedChannel && lock.channel !== expectedChannel)
147
+ throw new Error(`harness '${harness}' requests ${expectedChannel} but its exact lock is ${lock.channel} `
148
+ + `(${lock.version}); run \`ours-fleet plugins update ${harness}\``);
149
+ if (lock)
150
+ writeMarketplace(lock);
151
+ return lock;
152
+ }
153
+ async function checked(exec, command, args, action) {
154
+ const result = await exec(command, args);
155
+ if (result.code !== 0)
156
+ throw new Error(`${action} failed (${command} ${args.join(' ')}): ${result.stderr.trim() || result.stdout.trim() || `exit ${result.code}`}`);
157
+ return result;
158
+ }
159
+ function parseJson(output, action) {
160
+ try {
161
+ return JSON.parse(output);
162
+ }
163
+ catch (error) {
164
+ throw new Error(`${action} returned invalid JSON: ${error.message}`);
165
+ }
166
+ }
167
+ async function resolveExactVersion(harness, channel, exec, now) {
168
+ const spec = SPECS[harness];
169
+ const distTag = channel === 'stable' ? 'latest' : 'nightly';
170
+ const result = await checked(exec, 'npm', ['view', `${spec.package}@${distTag}`, 'version', '--json'], `resolve ${spec.package} ${distTag}`);
171
+ const version = parseJson(result.stdout, `npm view ${spec.package}@${distTag}`);
172
+ if (typeof version !== 'string' || !EXACT_SEMVER.test(version))
173
+ throw new Error(`npm view ${spec.package}@${distTag} did not return one exact semver`);
174
+ if (channel === 'stable' && version.includes('-'))
175
+ throw new Error(`refusing stable ${spec.package}: npm latest resolved to prerelease ${version}`);
176
+ if (channel === 'nightly' && !version.includes('-nightly.'))
177
+ throw new Error(`refusing nightly ${spec.package}: npm nightly resolved to ${version}`);
178
+ return {
179
+ schemaVersion: 1,
180
+ harness,
181
+ channel,
182
+ distTag,
183
+ package: spec.package,
184
+ version,
185
+ registry: REGISTRY,
186
+ resolvedAt: now().toISOString(),
187
+ };
188
+ }
189
+ function marketplaceEntries(harness, output) {
190
+ const parsed = parseJson(output, `${SPECS[harness].executable} plugin marketplace list`);
191
+ if (harness === 'codex') {
192
+ if (!isRecord(parsed) || !Array.isArray(parsed.marketplaces))
193
+ throw new Error('codex plugin marketplace list returned an unexpected shape');
194
+ return parsed.marketplaces.filter(isRecord);
195
+ }
196
+ if (!Array.isArray(parsed))
197
+ throw new Error('claude plugin marketplace list returned an unexpected shape');
198
+ return parsed.filter(isRecord);
199
+ }
200
+ async function ensureMarketplaceRegistered(harness, exec) {
201
+ const spec = SPECS[harness];
202
+ const root = harnessPluginMarketplaceRoot(harness);
203
+ const result = await checked(exec, spec.executable, ['plugin', 'marketplace', 'list', '--json'], `list ${harness} marketplaces`);
204
+ const existing = marketplaceEntries(harness, result.stdout)
205
+ .find(entry => entry.name === spec.marketplaceName);
206
+ if (existing) {
207
+ if (harness === 'codex') {
208
+ const declared = isRecord(existing.marketplaceSource)
209
+ ? existing.marketplaceSource.source : existing.root;
210
+ if (typeof declared !== 'string' || resolve(declared) !== resolve(root))
211
+ throw new Error(`marketplace '${spec.marketplaceName}' already exists but does not point to ${root}; `
212
+ + 'refusing to replace an unrelated marketplace');
213
+ }
214
+ else {
215
+ // Claude copies marketplace contents into its cache, but retains the
216
+ // source kind in list output. Refresh that copy after each lock update.
217
+ if (existing.source !== 'directory')
218
+ throw new Error(`marketplace '${spec.marketplaceName}' already exists but is not a local directory; `
219
+ + 'refusing to replace an unrelated marketplace');
220
+ await checked(exec, 'claude', ['plugin', 'marketplace', 'update', spec.marketplaceName], `refresh ${harness} locked marketplace`);
221
+ }
222
+ return;
223
+ }
224
+ const args = harness === 'codex'
225
+ ? ['plugin', 'marketplace', 'add', root, '--json']
226
+ : ['plugin', 'marketplace', 'add', root, '--scope', 'user'];
227
+ await checked(exec, spec.executable, args, `register ${harness} locked marketplace`);
228
+ }
229
+ async function removeLegacyPluginSelections(harness, exec) {
230
+ const spec = SPECS[harness];
231
+ const result = await checked(exec, spec.executable, ['plugin', 'list', '--json'], `list ${harness} plugins`);
232
+ const parsed = parseJson(result.stdout, `${spec.executable} plugin list`);
233
+ if (harness === 'codex' && (!isRecord(parsed) || !Array.isArray(parsed.installed)))
234
+ throw new Error('codex plugin list returned an unexpected shape');
235
+ if (harness === 'claude-code' && !Array.isArray(parsed))
236
+ throw new Error('claude plugin list returned an unexpected shape');
237
+ const entries = (harness === 'codex'
238
+ ? parsed.installed
239
+ : parsed).filter(isRecord);
240
+ const ids = new Set(entries
241
+ .map(entry => harness === 'codex' ? entry.pluginId : entry.id)
242
+ .filter((id) => typeof id === 'string'));
243
+ const legacy = harness === 'codex'
244
+ ? ['ours@ours-codex-marketplace', 'ours-fleet@ours-codex-marketplace']
245
+ : ['ours@ours', 'ours@ours.network'];
246
+ for (const selector of legacy.filter(id => ids.has(id))) {
247
+ const args = harness === 'codex'
248
+ ? ['plugin', 'remove', selector, '--json']
249
+ : ['plugin', 'uninstall', selector, '--scope', 'user', '--keep-data'];
250
+ await checked(exec, spec.executable, args, `remove legacy moving selection ${selector}`);
251
+ }
252
+ }
253
+ async function installFromLock(lock, exec) {
254
+ const spec = SPECS[lock.harness];
255
+ // The Codex package also owns the ours-codex launcher. Pin that executable to
256
+ // the same exact artifact as the local marketplace; never invoke its broad
257
+ // installer, which may select daemon/SDK packages outside this feature.
258
+ if (lock.harness === 'codex') {
259
+ await checked(exec, 'npm', ['install', '--global', `${lock.package}@${lock.version}`], `install ${lock.package}@${lock.version}`);
260
+ }
261
+ await ensureMarketplaceRegistered(lock.harness, exec);
262
+ const selector = `ours@${spec.marketplaceName}`;
263
+ const args = lock.harness === 'codex'
264
+ ? ['plugin', 'add', selector, '--json']
265
+ : ['plugin', 'install', selector, '--scope', 'user'];
266
+ await checked(exec, spec.executable, args, `install ${selector}`);
267
+ // The same plugin under an older Git marketplace is a distinct harness
268
+ // selection and can remain enabled beside the lock. Remove only the known
269
+ // ours selectors, after the exact local selection is installed successfully.
270
+ await removeLegacyPluginSelections(lock.harness, exec);
271
+ }
272
+ /**
273
+ * Explicit install/update transaction.
274
+ *
275
+ * install reuses an existing same-channel lock (deterministic repair/reinstall);
276
+ * update always resolves the requested channel once and advances the lock.
277
+ */
278
+ export async function installHarnessPlugin(harness, channel, options = {}) {
279
+ const exec = options.exec ?? realExec;
280
+ const root = harnessPluginRoot(harness);
281
+ return withFileLock(`${root}.lock`, async () => {
282
+ const existing = readHarnessPluginLock(harness);
283
+ const mustResolve = options.update === true || !existing || existing.channel !== channel;
284
+ const lock = mustResolve
285
+ ? await resolveExactVersion(harness, channel, exec, options.now ?? (() => new Date()))
286
+ : existing;
287
+ if (mustResolve)
288
+ replaceFileAtomically(harnessPluginLockPath(harness), `${JSON.stringify(lock, null, 2)}\n`);
289
+ // Publish the lock first. If installation is interrupted, every retry uses
290
+ // this same exact version; no half-finished transaction can observe a newer
291
+ // dist-tag on its own.
292
+ const marketplacePath = writeMarketplace(lock);
293
+ await installFromLock(lock, exec);
294
+ return {
295
+ lock,
296
+ lockPath: harnessPluginLockPath(harness),
297
+ marketplacePath,
298
+ resolved: mustResolve,
299
+ };
300
+ });
301
+ }
302
+ export function selectedHarnessPluginIds(values) {
303
+ if (!values.length)
304
+ return [...HARNESS_PLUGIN_IDS];
305
+ const unknown = values.filter(value => !HARNESS_PLUGIN_IDS.includes(value));
306
+ if (unknown.length)
307
+ throw new Error(`unknown harness(es) ${unknown.join(', ')}; allowed: ${HARNESS_PLUGIN_IDS.join(', ')}`);
308
+ return [...new Set(values)];
309
+ }
package/dist/index.d.ts CHANGED
@@ -28,3 +28,5 @@ export { doctor } from './doctor.js';
28
28
  export { runOnce, runTemp } from './runner.js';
29
29
  export { Tmux } from './tmux.js';
30
30
  export { VERSION } from './version.js';
31
+ export { HARNESS_PLUGIN_IDS, installHarnessPlugin, readHarnessPluginLock, restoreLockedHarnessMarketplace, resolveHarnessPluginConfigs, } from './harness-plugins.js';
32
+ export type { HarnessPluginId, HarnessPluginChannel, HarnessPluginConfig, HarnessPluginConfigs, HarnessPluginLock, HarnessPluginInstallResult, } from './harness-plugins.js';
package/dist/index.js CHANGED
@@ -23,3 +23,4 @@ export { doctor } from './doctor.js';
23
23
  export { runOnce, runTemp } from './runner.js';
24
24
  export { Tmux } from './tmux.js';
25
25
  export { VERSION } from './version.js';
26
+ export { HARNESS_PLUGIN_IDS, installHarnessPlugin, readHarnessPluginLock, restoreLockedHarnessMarketplace, resolveHarnessPluginConfigs, } from './harness-plugins.js';
@@ -68,8 +68,32 @@ export declare class ScheduledLoopManager implements ScheduledLoopManagerHandle
68
68
  private armAbandon;
69
69
  private finish;
70
70
  private advance;
71
+ /**
72
+ * Coalesce a backlog into one skip. The counters alone say how many
73
+ * occurrences were lost but never when or for how long, so the window is
74
+ * recorded too and carried on the state until a run is actually told about it
75
+ * — a dropped pass has to stay visible to the next one, not just to whoever
76
+ * was reading the log at the time.
77
+ */
71
78
  private skipMissed;
72
- private skipRestartMisses;
79
+ /**
80
+ * Restart is not, by itself, a reason to lose an occurrence a running manager
81
+ * would still have run. `poll` tolerates lateness up to one full interval and
82
+ * runs the tick late; this path used to drop anything already due however
83
+ * recently, so a role restarted seconds after its own tick came due lost it
84
+ * outright. For an oversight role that is precisely the pass which would have
85
+ * recorded why it restarted, so the failure erased its own witness.
86
+ *
87
+ * The tolerance is the only thing shared with `poll`. A backlog at least one
88
+ * interval deep is still coalesced into a single skip and never replayed —
89
+ * after a long outage exactly one occurrence survives, and `schedule` then
90
+ * arms it through the ordinary path rather than firing a burst here.
91
+ *
92
+ * Running the survivor late cannot outpace the configured cadence: `advance`
93
+ * moves the cursor by exactly one `intervalMs` per occurrence from the nominal
94
+ * time, so a loop that keeps restarting still runs at most once per interval.
95
+ */
96
+ private skipRestartBacklog;
73
97
  /**
74
98
  * A run the store could not record is dropped, not retried: the cursor has
75
99
  * already moved, so this can never become a busy loop, and the outage is
@@ -86,5 +110,10 @@ export declare class ScheduledLoopManager implements ScheduledLoopManagerHandle
86
110
  * until the process was restarted.
87
111
  */
88
112
  private recover;
113
+ /**
114
+ * The envelope is the only channel a scheduled pass has for learning about
115
+ * the passes that did not happen. A gap stated here is what lets an oversight
116
+ * role report its own outage instead of resuming as if nothing was missed.
117
+ */
89
118
  private envelope;
90
119
  }
@@ -39,7 +39,7 @@ export class ScheduledLoopManager {
39
39
  }
40
40
  start() {
41
41
  if (!this.store.fresh)
42
- this.skipRestartMisses();
42
+ this.skipRestartBacklog();
43
43
  this.schedule();
44
44
  }
45
45
  async stop() {
@@ -143,10 +143,15 @@ export class ScheduledLoopManager {
143
143
  async attempt(definition, state, scheduledAt) {
144
144
  const runId = `sl_${randomUUID()}`;
145
145
  const origin = { kind: 'scheduled-loop', loop: definition.name, runId };
146
- const prompt = this.envelope(definition, runId, scheduledAt);
146
+ // The gap is read here and cleared only if the turn is actually admitted:
147
+ // an attempt that ends `skipped_busy` or `unavailable` reported it to
148
+ // nobody, so it has to still be there for the attempt that succeeds.
149
+ const gap = state.missedGap;
150
+ const prompt = this.envelope(definition, runId, scheduledAt, gap);
147
151
  let claimed = false;
148
152
  const result = await this.arbiter.tryScheduled(prompt, origin, () => {
149
153
  claimed = true;
154
+ state.missedGap = null;
150
155
  state.activeRunId = runId;
151
156
  state.lastRunId = runId;
152
157
  state.lastStartedAt = new Date(this.deps.now()).toISOString();
@@ -265,7 +270,15 @@ export class ScheduledLoopManager {
265
270
  state.nextScheduledAt = new Date(next).toISOString();
266
271
  state.nextDueAt = new Date(next + deterministicJitter(this.role, definition.name, next, definition.jitterMs)).toISOString();
267
272
  }
273
+ /**
274
+ * Coalesce a backlog into one skip. The counters alone say how many
275
+ * occurrences were lost but never when or for how long, so the window is
276
+ * recorded too and carried on the state until a run is actually told about it
277
+ * — a dropped pass has to stay visible to the next one, not just to whoever
278
+ * was reading the log at the time.
279
+ */
268
280
  skipMissed(definition, state, now) {
281
+ const from = state.nextScheduledAt;
269
282
  let missed = 0;
270
283
  while (Date.parse(state.nextDueAt) <= now) {
271
284
  this.advance(definition, state);
@@ -275,14 +288,43 @@ export class ScheduledLoopManager {
275
288
  state.counts.skippedMissed = increment(state.counts.skippedMissed, missed);
276
289
  state.lastOutcome = 'skipped_missed';
277
290
  state.lastFinishedAt = new Date(now).toISOString();
291
+ // Successive outages before any run lands merge into one gap: the earliest
292
+ // start wins, so the window always spans the whole silence.
293
+ const previous = state.missedGap;
294
+ state.missedGap = {
295
+ count: increment(previous?.count ?? 0, missed),
296
+ fromAt: previous?.fromAt ?? from,
297
+ throughAt: state.lastScheduledAt ?? from,
298
+ detectedAt: new Date(now).toISOString(),
299
+ };
278
300
  this.store.persist();
279
- this.deps.log(`[${this.role}] loop ${definition.name} skipped_missed count=${missed}`);
301
+ this.deps.log(`[${this.role}] loop ${definition.name} skipped_missed count=${missed} `
302
+ + `gap=${from}..${state.missedGap.throughAt} `
303
+ + `unreported=${state.missedGap.count}`);
280
304
  }
281
- skipRestartMisses() {
305
+ /**
306
+ * Restart is not, by itself, a reason to lose an occurrence a running manager
307
+ * would still have run. `poll` tolerates lateness up to one full interval and
308
+ * runs the tick late; this path used to drop anything already due however
309
+ * recently, so a role restarted seconds after its own tick came due lost it
310
+ * outright. For an oversight role that is precisely the pass which would have
311
+ * recorded why it restarted, so the failure erased its own witness.
312
+ *
313
+ * The tolerance is the only thing shared with `poll`. A backlog at least one
314
+ * interval deep is still coalesced into a single skip and never replayed —
315
+ * after a long outage exactly one occurrence survives, and `schedule` then
316
+ * arms it through the ordinary path rather than firing a burst here.
317
+ *
318
+ * Running the survivor late cannot outpace the configured cadence: `advance`
319
+ * moves the cursor by exactly one `intervalMs` per occurrence from the nominal
320
+ * time, so a loop that keeps restarting still runs at most once per interval.
321
+ */
322
+ skipRestartBacklog() {
282
323
  const now = this.deps.now();
283
324
  for (const definition of this.definitions.values()) {
284
325
  const state = this.store.state.loops[definition.name];
285
- if (definition.enabled && !state.operatorDisabled && Date.parse(state.nextDueAt) <= now)
326
+ if (definition.enabled && !state.operatorDisabled
327
+ && now >= Date.parse(state.nextDueAt) + definition.intervalMs)
286
328
  this.skipMissed(definition, state, now);
287
329
  }
288
330
  }
@@ -340,17 +382,38 @@ export class ScheduledLoopManager {
340
382
  this.deps.clearTimer(this.timer);
341
383
  this.arm(backoffMs(this.pollFailures));
342
384
  }
343
- envelope(definition, runId, scheduledAt) {
385
+ /**
386
+ * The envelope is the only channel a scheduled pass has for learning about
387
+ * the passes that did not happen. A gap stated here is what lets an oversight
388
+ * role report its own outage instead of resuming as if nothing was missed.
389
+ */
390
+ envelope(definition, runId, scheduledAt, gap) {
391
+ const lateBy = Math.max(0, this.deps.now() - scheduledAt);
344
392
  return [
345
393
  '[fleet-loop]',
346
394
  `loop: ${definition.name}`,
347
395
  `run: ${runId}`,
348
396
  `scheduled_at: ${new Date(scheduledAt).toISOString()}`,
397
+ ...(lateBy > 0 ? [`started_late_by_ms: ${lateBy}`] : []),
398
+ ...(gap ? [
399
+ `missed_occurrences: ${gap.count}`,
400
+ `missed_window: ${gap.fromAt}..${gap.throughAt}`,
401
+ `missed_gap_ms: ${Math.max(0, Date.parse(gap.detectedAt) - Date.parse(gap.fromAt))}`,
402
+ ] : []),
349
403
  'origin: local-trusted-config',
350
404
  '',
351
405
  'This is a scheduled internal maintenance turn, not an owner message and not ordinary ours mail.',
352
406
  'Perform one bounded pass. Do not wait for the next tick. Do not report to an owner unless your',
353
407
  'configured policy and an existing authenticated proactive-report route authorize a material report.',
408
+ // Same single route as the owner-request prompt, and for the same reason.
409
+ 'To send a file, call ours `send_file` with the recipient and the path — to your owner-channel',
410
+ 'identity if this role has one, otherwise directly to the contact who should receive it.',
411
+ 'A file written anywhere else is not delivered and nothing will report that it was not.',
412
+ ...(gap ? ['',
413
+ 'This loop did not run for the window above: those occurrences were coalesced away while the role',
414
+ 'was unavailable, and this pass is the first since. Treat the gap as part of what you are reporting',
415
+ 'on — it is the record of your own outage, and no later pass will be told about it.',
416
+ ] : []),
354
417
  '',
355
418
  definition.prompt,
356
419
  ].join('\n');
@@ -8,11 +8,29 @@ export interface LoopCounts {
8
8
  skippedBusy: number;
9
9
  skippedMissed: number;
10
10
  }
11
+ /**
12
+ * A coalesced run of occurrences that were never submitted, held until a run
13
+ * actually starts and can be told about it. Without it a dropped occurrence
14
+ * survives only as a counter, which says how many were lost but never when or
15
+ * for how long — and an oversight role cannot report an outage it cannot date.
16
+ */
17
+ export interface LoopMissedGap {
18
+ /** Occurrences coalesced away, summed across every skip since the last run. */
19
+ count: number;
20
+ /** Nominal time of the earliest occurrence in the gap. */
21
+ fromAt: string;
22
+ /** Nominal time of the latest occurrence in the gap. */
23
+ throughAt: string;
24
+ /** When the manager noticed — the end of the outage, not of the last skip. */
25
+ detectedAt: string;
26
+ }
11
27
  export interface LoopRuntimeState {
12
28
  definitionHash: string;
13
29
  promptHash: string;
14
30
  enabled: boolean;
15
31
  operatorDisabled: boolean;
32
+ /** Unreported gap, cleared by the first run that carries it. */
33
+ missedGap: LoopMissedGap | null;
16
34
  nextScheduledAt: string;
17
35
  nextDueAt: string;
18
36
  lastScheduledAt: string | null;