@ours.network/fleet 1.1.5 → 1.2.0-nightly.10
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/README.md +163 -21
- package/dist/agent-ours/bridge.d.ts +3 -0
- package/dist/agent-ours/bridge.js +178 -0
- package/dist/agent-ours/controller.d.ts +14 -0
- package/dist/agent-ours/controller.js +60 -0
- package/dist/agent-ours/file-rpc.d.ts +23 -0
- package/dist/agent-ours/file-rpc.js +123 -0
- package/dist/agent-ours/harness.d.ts +14 -0
- package/dist/agent-ours/harness.js +64 -0
- package/dist/agent-ours/mcp-endpoint.d.ts +15 -0
- package/dist/agent-ours/mcp-endpoint.js +111 -0
- package/dist/agent-ours/runtime.d.ts +53 -0
- package/dist/agent-ours/runtime.js +247 -0
- package/dist/agent-ours/service.d.ts +23 -0
- package/dist/agent-ours/service.js +364 -0
- package/dist/agent-ours/state.d.ts +45 -0
- package/dist/agent-ours/state.js +95 -0
- package/dist/agent-ours/wire.d.ts +14 -0
- package/dist/agent-ours/wire.js +55 -0
- package/dist/agent-recovery-gate.js +1 -1
- package/dist/application/legacy-supervisor-identity.d.ts +7 -0
- package/dist/application/legacy-supervisor-identity.js +43 -0
- package/dist/application/supervisor-ours-tools.d.ts +159 -0
- package/dist/application/supervisor-ours-tools.js +121 -0
- package/dist/application/task-room-service.js +55 -19
- package/dist/briefing.js +10 -59
- package/dist/build-info.json +6 -5
- package/dist/capabilities.d.ts +2 -1
- package/dist/capabilities.js +2 -0
- package/dist/cli.js +75 -8
- package/dist/client-profile.d.ts +18 -0
- package/dist/client-profile.js +130 -0
- package/dist/config.d.ts +1 -0
- package/dist/creation.d.ts +1 -1
- package/dist/creation.js +13 -21
- package/dist/daemon-recovery.d.ts +2 -0
- package/dist/daemon-recovery.js +53 -2
- package/dist/docs.d.ts +3 -3
- package/dist/docs.js +32 -23
- package/dist/doctor.d.ts +1 -1
- package/dist/doctor.js +70 -14
- package/dist/fleet-command-audit.js +3 -2
- package/dist/harness/agent-session.d.ts +2 -0
- package/dist/harness/claude-code-session.js +4 -2
- package/dist/harness/codex-session.js +9 -2
- package/dist/harness/codex.d.ts +3 -2
- package/dist/harness/codex.js +8 -3
- package/dist/harness/hermes-session.js +7 -1
- package/dist/init-wizard.d.ts +11 -6
- package/dist/init-wizard.js +33 -0
- package/dist/monitor.d.ts +34 -3
- package/dist/monitor.js +174 -74
- package/dist/owner-channel/channel.d.ts +3 -0
- package/dist/owner-channel/channel.js +28 -6
- package/dist/owner-channel/ours-client.d.ts +12 -5
- package/dist/owner-channel/ours-client.js +48 -12
- package/dist/rooms-tasks/archived-absence.d.ts +8 -0
- package/dist/rooms-tasks/archived-absence.js +48 -0
- package/dist/rooms-tasks/cli.js +1 -1
- package/dist/rooms-tasks/close.d.ts +3 -0
- package/dist/rooms-tasks/close.js +95 -17
- package/dist/rooms-tasks/cowork-adapter.d.ts +4 -0
- package/dist/rooms-tasks/cowork-adapter.js +10 -2
- package/dist/rooms-tasks/cowork-http.d.ts +3 -0
- package/dist/rooms-tasks/cowork-http.js +70 -0
- package/dist/rooms-tasks/deletion.js +40 -3
- package/dist/rooms-tasks/member-startup.d.ts +1 -0
- package/dist/rooms-tasks/member-startup.js +2 -0
- package/dist/rooms-tasks/provision.d.ts +11 -0
- package/dist/rooms-tasks/provision.js +138 -26
- package/dist/rooms-tasks/room-state.js +13 -0
- package/dist/rooms-tasks/task-state.d.ts +13 -3
- package/dist/rooms-tasks/task-state.js +42 -6
- package/dist/rooms-tasks/types.d.ts +12 -0
- package/dist/rooms-tasks/workspace-artifacts.d.ts +6 -0
- package/dist/rooms-tasks/workspace-artifacts.js +93 -0
- package/dist/rooms-tasks/workspace.d.ts +26 -0
- package/dist/rooms-tasks/workspace.js +195 -0
- package/dist/runner.d.ts +14 -0
- package/dist/runner.js +540 -494
- package/dist/session/codex-app-server-transport.js +1 -1
- package/dist/session/codex-app-server.js +31 -5
- package/dist/spawn.js +19 -16
- package/dist/startup-readiness.d.ts +13 -0
- package/dist/startup-readiness.js +38 -0
- package/dist/supervisor/launchd.js +8 -1
- package/dist/supervisor/systemd.js +5 -0
- package/dist/temp-supervisor-recovery.d.ts +3 -0
- package/dist/temp-supervisor-recovery.js +46 -0
- package/dist/watchdog/briefing.js +3 -16
- package/dist/watchdog/run.js +19 -5
- package/dist/web/server.d.ts +2 -0
- package/dist/web/server.js +18 -1
- package/docs/task-workspaces.md +98 -0
- package/package.json +8 -4
- package/presets/fleet/roles/Coordinator.yaml +22 -0
|
@@ -23,6 +23,12 @@ export class CodexAgentSessionAdapter {
|
|
|
23
23
|
}
|
|
24
24
|
start(options) {
|
|
25
25
|
const { role, prep, launch } = options;
|
|
26
|
+
const original = this.strategy.nativeConfig(role) ?? {};
|
|
27
|
+
const managed = options.managedOurs?.native;
|
|
28
|
+
const config = managed ? { ...original, ...managed,
|
|
29
|
+
mcp_servers: { ...(original.mcp_servers ?? {}), ...(managed.mcp_servers ?? {}) },
|
|
30
|
+
plugins: { ...(original.plugins ?? {}), ...(managed.plugins ?? {}) },
|
|
31
|
+
} : original;
|
|
26
32
|
if (role.session === 'codex-app-server')
|
|
27
33
|
return this.nativeTransport({
|
|
28
34
|
name: role.name, argv: launch.argv, cwd: options.cwd, env: launch.env,
|
|
@@ -31,7 +37,7 @@ export class CodexAgentSessionAdapter {
|
|
|
31
37
|
model: role.model, effort: role.effort,
|
|
32
38
|
approvalPolicy: this.strategy.approvalPolicy(role),
|
|
33
39
|
sandbox: this.strategy.sandbox(role),
|
|
34
|
-
config
|
|
40
|
+
config,
|
|
35
41
|
addDirs: this.strategy.addDirs(role),
|
|
36
42
|
log: options.log,
|
|
37
43
|
});
|
|
@@ -40,7 +46,8 @@ export class CodexAgentSessionAdapter {
|
|
|
40
46
|
argv: launch.argv, cwd: options.cwd, env: launch.env,
|
|
41
47
|
stateDir: options.stateDir, mode: options.mode, permissions: options.permissions,
|
|
42
48
|
modeId: this.strategy.permissionModeId(role),
|
|
43
|
-
mcpServers: this.strategy.mcpServers(role),
|
|
49
|
+
mcpServers: options.managedOurs ? [...(this.strategy.mcpServers(role) ?? []).filter(s => s.name !== 'ours'), options.managedOurs.server] : this.strategy.mcpServers(role),
|
|
50
|
+
inheritEnvironment: options.managedOurs ? false : undefined,
|
|
44
51
|
sessionMeta: this.strategy.sessionMeta(role, prep),
|
|
45
52
|
configSelections: this.strategy.sessionConfigSelections(role),
|
|
46
53
|
permissionMode: options.permissionMode,
|
package/dist/harness/codex.d.ts
CHANGED
|
@@ -6,8 +6,9 @@ import type { AcpSessionTransport } from './acp-session-transport.js';
|
|
|
6
6
|
import type { CodexAppServerSessionTransport } from './codex-session.js';
|
|
7
7
|
/**
|
|
8
8
|
* What an unattended role can actually do under Codex's native settings.
|
|
9
|
-
* `on-request` and `untrusted`
|
|
10
|
-
*
|
|
9
|
+
* `on-request` and `untrusted` can ask even for startup file reads (for example,
|
|
10
|
+
* shell reads or files outside the sandbox). Without a controller the request
|
|
11
|
+
* waits or is denied according to policy. Neither guarantees `read-state`.
|
|
11
12
|
*/
|
|
12
13
|
export declare function codexCapabilities(approval: string, sandbox: string): UnattendedCapability[];
|
|
13
14
|
/** Defense in depth for callers which launch a role after validation was bypassed. */
|
package/dist/harness/codex.js
CHANGED
|
@@ -25,12 +25,13 @@ const CODEX_PROXY_REAL_PATH_ENV = 'OURS_FLEET_REAL_CODEX_PATH';
|
|
|
25
25
|
const CODEX_PROXY_MANIFEST_ENV = 'OURS_FLEET_CODEX_ACP_MANIFEST';
|
|
26
26
|
/**
|
|
27
27
|
* What an unattended role can actually do under Codex's native settings.
|
|
28
|
-
* `on-request` and `untrusted`
|
|
29
|
-
*
|
|
28
|
+
* `on-request` and `untrusted` can ask even for startup file reads (for example,
|
|
29
|
+
* shell reads or files outside the sandbox). Without a controller the request
|
|
30
|
+
* waits or is denied according to policy. Neither guarantees `read-state`.
|
|
30
31
|
*/
|
|
31
32
|
export function codexCapabilities(approval, sandbox) {
|
|
32
33
|
if (approval !== 'never')
|
|
33
|
-
return [
|
|
34
|
+
return [];
|
|
34
35
|
const caps = ['read-state', 'messaging', 'monitor', 'status-commands'];
|
|
35
36
|
if (sandbox !== 'read-only')
|
|
36
37
|
caps.push('write-state', 'workspace-edit');
|
|
@@ -167,6 +168,10 @@ function codexAgentLaunch(role, prep) {
|
|
|
167
168
|
...(options?.search ? ['--search'] : []),
|
|
168
169
|
'app-server',
|
|
169
170
|
];
|
|
171
|
+
// Fleet delivers wakes through the native session itself. The ours-codex
|
|
172
|
+
// launcher owns a remote TUI and cannot serve this stdio transport.
|
|
173
|
+
if (launcher === 'auto' && role.monitor?.mode === 'fleet')
|
|
174
|
+
return { argv: ['codex', ...flags], env: prep.env };
|
|
170
175
|
// Preserve the established `auto` launcher contract without resolving PATH
|
|
171
176
|
// during synchronous launch preparation. The static shell fragment passes
|
|
172
177
|
// every dynamic value as an argv element and `exec`s the selected process.
|
|
@@ -53,6 +53,12 @@ export class HermesAgentSessionAdapter {
|
|
|
53
53
|
// composition; no second ambient merge may reintroduce provider secrets.
|
|
54
54
|
const env = hermesChildEnvironment(role, preparedHome(prep), launch.env, launch.env);
|
|
55
55
|
delete env.OURS_AUTOSTART;
|
|
56
|
+
if (options.managedOurs) {
|
|
57
|
+
for (const key of Object.keys(env))
|
|
58
|
+
if (key.startsWith('OURS_') && !key.startsWith('OURS_FLEET_'))
|
|
59
|
+
delete env[key];
|
|
60
|
+
Object.assign(env, { FLEET_OURS_MANAGED: '1' });
|
|
61
|
+
}
|
|
56
62
|
await this.checks.preflight?.(options, env);
|
|
57
63
|
const nativeProvider = await this.checks.expectedProvider(role, prep);
|
|
58
64
|
if (typeof nativeProvider !== 'string' || !nativeProvider.trim())
|
|
@@ -68,7 +74,7 @@ export class HermesAgentSessionAdapter {
|
|
|
68
74
|
permissions: options.permissions, modeId, requireMode: true,
|
|
69
75
|
permissionMode: { fleetMode: options.permissionMode.fleetMode, nativeMode: modeId },
|
|
70
76
|
permissionTimeoutMs: 50_000,
|
|
71
|
-
mcpServers: hermesMcpServers(role, env),
|
|
77
|
+
mcpServers: options.managedOurs ? [...hermesMcpServers(role, env).filter(s => s.name !== 'ours'), options.managedOurs.server] : hermesMcpServers(role, env),
|
|
72
78
|
scrubObsoleteOursAutostart: true,
|
|
73
79
|
validateStartupResponse: async (initialized, created) => {
|
|
74
80
|
await this.checks.validateArtifact(initialized);
|
package/dist/init-wizard.d.ts
CHANGED
|
@@ -42,18 +42,23 @@ export interface InitPublishResult {
|
|
|
42
42
|
manifestExisted: boolean;
|
|
43
43
|
rootExisted: boolean;
|
|
44
44
|
}
|
|
45
|
+
export interface InitExecutionDeps {
|
|
46
|
+
hostSetup(): Promise<void>;
|
|
47
|
+
publish(configuration: string, setup: GeneratedSetup): Promise<InitPublishResult>;
|
|
48
|
+
generate?(answers: InitAnswers): GeneratedSetup;
|
|
49
|
+
preflight?(configuration: string): InitPathState;
|
|
50
|
+
}
|
|
45
51
|
export declare function validateCatalog(value: BrainCatalog, path?: string): BrainCatalog;
|
|
52
|
+
/** Read and validate the complete public InitAnswers JSON shape without side effects. */
|
|
53
|
+
export declare function readInitSettings(path: string): InitAnswers;
|
|
46
54
|
export declare function askInitQuestions(prompter: InitPrompter, configuration: string): Promise<InitAnswers | undefined>;
|
|
47
55
|
export declare function formatSetupSummary(answers: InitAnswers, configuration: string): string;
|
|
48
56
|
/** Build the exact, deterministic default experience without writing to disk. */
|
|
49
57
|
export declare function generateSetup(answers: InitAnswers): GeneratedSetup;
|
|
50
58
|
/** Run the mutation boundary only after the complete questionnaire is accepted. */
|
|
51
|
-
export declare function executeInitWizard(prompter: InitPrompter, configuration: string, deps:
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
generate?(answers: InitAnswers): GeneratedSetup;
|
|
55
|
-
preflight?(configuration: string): InitPathState;
|
|
56
|
-
}): Promise<InitPublishResult | undefined>;
|
|
59
|
+
export declare function executeInitWizard(prompter: InitPrompter, configuration: string, deps: InitExecutionDeps): Promise<InitPublishResult | undefined>;
|
|
60
|
+
/** Execute already-collected answers through the same validation and mutation boundary. */
|
|
61
|
+
export declare function executeInitAnswers(answers: InitAnswers, configuration: string, deps: InitExecutionDeps): Promise<InitPublishResult>;
|
|
57
62
|
export interface InitPathInspectionDeps {
|
|
58
63
|
exists?(path: string): boolean;
|
|
59
64
|
lstat?(path: string): Stats;
|
package/dist/init-wizard.js
CHANGED
|
@@ -49,6 +49,35 @@ function catalog() {
|
|
|
49
49
|
const path = join(packagedPresetRoot(), 'brain-catalog.json');
|
|
50
50
|
return validateCatalog(JSON.parse(readFileSync(path, 'utf8')), path);
|
|
51
51
|
}
|
|
52
|
+
const hasExactKeys = (value, keys) => Object.keys(value).sort().join('\0') === [...keys].sort().join('\0');
|
|
53
|
+
/** Read and validate the complete public InitAnswers JSON shape without side effects. */
|
|
54
|
+
export function readInitSettings(path) {
|
|
55
|
+
let value;
|
|
56
|
+
try {
|
|
57
|
+
value = JSON.parse(readFileSync(resolve(path), 'utf8'));
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
throw new Error(`cannot read valid Fleet init settings ${path}: ${error.message}`, { cause: error });
|
|
61
|
+
}
|
|
62
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)
|
|
63
|
+
|| !hasExactKeys(value, ['subscriptions', 'assignmentStrategy', 'models', 'reasoning']))
|
|
64
|
+
throw new Error('Fleet init settings must contain exactly subscriptions, assignmentStrategy, models, and reasoning');
|
|
65
|
+
const candidate = value;
|
|
66
|
+
const models = candidate.models;
|
|
67
|
+
if (!models || typeof models !== 'object' || Array.isArray(models)
|
|
68
|
+
|| !hasExactKeys(models, WORK_KINDS))
|
|
69
|
+
throw new Error('Fleet init settings models must contain exactly development, review, and coordination');
|
|
70
|
+
for (const work of WORK_KINDS) {
|
|
71
|
+
const model = models[work];
|
|
72
|
+
if (!model || typeof model !== 'object' || Array.isArray(model)
|
|
73
|
+
|| !hasExactKeys(model, ['harness', 'session', 'model', 'efforts']))
|
|
74
|
+
throw new Error(`Fleet init settings ${work} model must contain exactly harness, session, model, and efforts`);
|
|
75
|
+
}
|
|
76
|
+
const answers = value;
|
|
77
|
+
// Generation is the single semantic/catalog validator for wizard and file input.
|
|
78
|
+
generateSetup(answers);
|
|
79
|
+
return answers;
|
|
80
|
+
}
|
|
52
81
|
const subscriptionFor = (model) => model.harness === 'codex' ? 'codex' : 'claude';
|
|
53
82
|
export async function askInitQuestions(prompter, configuration) {
|
|
54
83
|
const configPath = resolve(configuration);
|
|
@@ -216,6 +245,10 @@ export async function executeInitWizard(prompter, configuration, deps) {
|
|
|
216
245
|
const answers = await askInitQuestions(prompter, configuration);
|
|
217
246
|
if (!answers)
|
|
218
247
|
return undefined;
|
|
248
|
+
return executeInitAnswers(answers, configuration, deps);
|
|
249
|
+
}
|
|
250
|
+
/** Execute already-collected answers through the same validation and mutation boundary. */
|
|
251
|
+
export async function executeInitAnswers(answers, configuration, deps) {
|
|
219
252
|
// Resolve every catalog/preset dependency before crossing the first mutation
|
|
220
253
|
// boundary. Production owns this new map; the optional seam forces failures
|
|
221
254
|
// deterministically in tests.
|
package/dist/monitor.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type AttachOursClientOptions } from '@ours.network/sdk/client';
|
|
1
2
|
import type { MonitorConfig, MonitorInterrupt, NotifyEventType } from './config.js';
|
|
2
3
|
import { type FailureEvidence } from './model-recovery.js';
|
|
3
4
|
/** A content-free arrival event as the daemon serves it over the notifications API. */
|
|
@@ -26,6 +27,28 @@ export type FetchLike = (url: string, init?: {
|
|
|
26
27
|
headers?: Record<string, string>;
|
|
27
28
|
signal?: AbortSignal;
|
|
28
29
|
}) => Promise<FetchResponse>;
|
|
30
|
+
export interface MonitorPageClient {
|
|
31
|
+
readNotificationPage(identity: string, options?: {
|
|
32
|
+
since?: number | 'tip';
|
|
33
|
+
signal?: AbortSignal;
|
|
34
|
+
requestTimeoutMs?: number;
|
|
35
|
+
}): Promise<{
|
|
36
|
+
cursor: number;
|
|
37
|
+
events: Array<Record<string, unknown>>;
|
|
38
|
+
}>;
|
|
39
|
+
close(): Promise<void>;
|
|
40
|
+
}
|
|
41
|
+
export interface IdentityProbeClient {
|
|
42
|
+
identities(): Promise<Array<string | {
|
|
43
|
+
name?: unknown;
|
|
44
|
+
temporary?: unknown;
|
|
45
|
+
stale?: unknown;
|
|
46
|
+
}>>;
|
|
47
|
+
close(): Promise<void>;
|
|
48
|
+
}
|
|
49
|
+
export interface IdentityProbeDeps {
|
|
50
|
+
attachClient?(options: AttachOursClientOptions): IdentityProbeClient | Promise<IdentityProbeClient>;
|
|
51
|
+
}
|
|
29
52
|
export interface MonitorDeps {
|
|
30
53
|
fetch: FetchLike;
|
|
31
54
|
isAlive(pid: number): boolean;
|
|
@@ -37,6 +60,8 @@ export interface MonitorDeps {
|
|
|
37
60
|
set(fn: () => void, ms: number): ReturnType<typeof setTimeout>;
|
|
38
61
|
clear(t: ReturnType<typeof setTimeout>): void;
|
|
39
62
|
};
|
|
63
|
+
/** Test seam; production attaches through the SDK's verified selection path. */
|
|
64
|
+
attachClient?(options: AttachOursClientOptions): MonitorPageClient | Promise<MonitorPageClient>;
|
|
40
65
|
/**
|
|
41
66
|
* Structured prompt delivery used by agent sessions.
|
|
42
67
|
* `succeeded` is the turn's TERMINAL result, not merely that the session took
|
|
@@ -101,13 +126,13 @@ export type IdentityPresence = {
|
|
|
101
126
|
detail: string;
|
|
102
127
|
};
|
|
103
128
|
/** Resolve the daemon endpoint + auth header from env → config → defaults. */
|
|
104
|
-
export declare function resolveEndpoint(env: NodeJS.ProcessEnv): DaemonEndpoint;
|
|
129
|
+
export declare function resolveEndpoint(env: NodeJS.ProcessEnv, includeToken?: boolean): DaemonEndpoint;
|
|
105
130
|
/**
|
|
106
131
|
* Ask the daemon's authoritative identity index. The notifications endpoint is
|
|
107
132
|
* intentionally unsuitable for lifecycle: it serves an empty 200 page for a
|
|
108
133
|
* valid but missing identity, which made a closed temp identity look healthy.
|
|
109
134
|
*/
|
|
110
|
-
export declare function probeIdentityPresence(name: string, fetch: FetchLike, env: NodeJS.ProcessEnv): Promise<IdentityPresence>;
|
|
135
|
+
export declare function probeIdentityPresence(name: string, fetch: FetchLike, env: NodeJS.ProcessEnv, deps?: IdentityProbeDeps): Promise<IdentityPresence>;
|
|
111
136
|
/** Actionable, secret-free description of every token source for this profile. */
|
|
112
137
|
export declare function authResolutionHint(ep: DaemonEndpoint): string;
|
|
113
138
|
/** Keep only the events whose type the role asked to wake on. */
|
|
@@ -171,7 +196,11 @@ export declare class Monitor {
|
|
|
171
196
|
private readonly identity;
|
|
172
197
|
private readonly cfg;
|
|
173
198
|
private readonly deps;
|
|
174
|
-
private readonly ep
|
|
199
|
+
private readonly ep?;
|
|
200
|
+
private readonly profile?;
|
|
201
|
+
private readonly profileKey;
|
|
202
|
+
private readonly monitorLeaseToken;
|
|
203
|
+
private profileClientPromise?;
|
|
175
204
|
private readonly statusPath;
|
|
176
205
|
private readonly cursorPath;
|
|
177
206
|
private readonly statePath;
|
|
@@ -212,6 +241,8 @@ export declare class Monitor {
|
|
|
212
241
|
* no-ops (delivery is still verified downstream).
|
|
213
242
|
*/
|
|
214
243
|
private doFetch;
|
|
244
|
+
private profileClient;
|
|
245
|
+
private disposeProfileClient;
|
|
215
246
|
private advance;
|
|
216
247
|
private persistCursor;
|
|
217
248
|
private readPersistedCursor;
|
package/dist/monitor.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
2
3
|
import { homedir } from 'node:os';
|
|
3
4
|
import { join } from 'node:path';
|
|
5
|
+
import { attachOursClient } from '@ours.network/sdk/client';
|
|
6
|
+
import { clientProfileKey, readClientProfile, } from './client-profile.js';
|
|
4
7
|
// Code constants rather than user configuration.
|
|
5
8
|
const DEFAULT_PORT = 3050;
|
|
6
9
|
// The daemon normally holds for 25s, but that value is operator-configurable
|
|
@@ -92,12 +95,12 @@ export function resolveApiToken(env, file = readDaemonConfig(env)) {
|
|
|
92
95
|
return undefined;
|
|
93
96
|
}
|
|
94
97
|
/** Resolve the daemon endpoint + auth header from env → config → defaults. */
|
|
95
|
-
export function resolveEndpoint(env) {
|
|
98
|
+
export function resolveEndpoint(env, includeToken = true) {
|
|
96
99
|
const file = readDaemonConfig(env);
|
|
97
100
|
const port = envInt(env, 'OURS_PORT') ?? file.port ?? DEFAULT_PORT;
|
|
98
101
|
const configPath = daemonConfigPath(env);
|
|
99
102
|
const stateDir = env.OURS_STATE_DIR ?? file.stateDir ?? join(homedir(), '.ours');
|
|
100
|
-
const token = resolveApiToken(env, file);
|
|
103
|
+
const token = includeToken ? resolveApiToken(env, file) : undefined;
|
|
101
104
|
const origin = `http://127.0.0.1:${port}`;
|
|
102
105
|
return {
|
|
103
106
|
origin,
|
|
@@ -113,7 +116,29 @@ export function resolveEndpoint(env) {
|
|
|
113
116
|
* intentionally unsuitable for lifecycle: it serves an empty 200 page for a
|
|
114
117
|
* valid but missing identity, which made a closed temp identity look healthy.
|
|
115
118
|
*/
|
|
116
|
-
export async function probeIdentityPresence(name, fetch, env) {
|
|
119
|
+
export async function probeIdentityPresence(name, fetch, env, deps = {}) {
|
|
120
|
+
const profile = readClientProfile(env);
|
|
121
|
+
if (profile) {
|
|
122
|
+
let client;
|
|
123
|
+
try {
|
|
124
|
+
client = await (deps.attachClient?.({
|
|
125
|
+
endpoint: profile.endpoint, expectedInstanceId: profile.expectedInstanceId,
|
|
126
|
+
credentialPath: profile.credentialPath,
|
|
127
|
+
sessionMode: 'external', leaseToken: randomUUID(), env: {},
|
|
128
|
+
}) ?? attachOursClient({
|
|
129
|
+
endpoint: profile.endpoint, expectedInstanceId: profile.expectedInstanceId,
|
|
130
|
+
credentialPath: profile.credentialPath,
|
|
131
|
+
sessionMode: 'external', leaseToken: randomUUID(), env: {},
|
|
132
|
+
}));
|
|
133
|
+
return classifyIdentityPresence(name, await client.identities());
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return { state: 'unknown', detail: 'selected identity index is unavailable' };
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
await client?.close().catch(() => undefined);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
117
142
|
const ep = resolveEndpoint(env);
|
|
118
143
|
let response;
|
|
119
144
|
try {
|
|
@@ -131,25 +156,36 @@ export async function probeIdentityPresence(name, fetch, env) {
|
|
|
131
156
|
};
|
|
132
157
|
try {
|
|
133
158
|
const body = await response.json();
|
|
134
|
-
|
|
135
|
-
return { state: 'unknown', detail: 'identity index response is malformed' };
|
|
136
|
-
// A healthy daemon normally has at least its Human identity. During daemon
|
|
137
|
-
// restart, however, the authenticated endpoint can briefly serve a valid
|
|
138
|
-
// but empty index while state is still loading. Empty is therefore not
|
|
139
|
-
// enough authority to retire a live temporary role.
|
|
140
|
-
if (body.identities.length === 0)
|
|
141
|
-
return { state: 'unknown', detail: 'identity index is temporarily empty' };
|
|
142
|
-
const found = body.identities.find(identity => (typeof identity === 'string' ? identity : identity?.name) === name);
|
|
143
|
-
if (!found)
|
|
144
|
-
return { state: 'absent' };
|
|
145
|
-
return typeof found === 'string'
|
|
146
|
-
? { state: 'present', temporary: false, stale: false }
|
|
147
|
-
: { state: 'present', temporary: found.temporary === true, stale: found.stale === true };
|
|
159
|
+
return classifyIdentityPresence(name, body.identities);
|
|
148
160
|
}
|
|
149
161
|
catch (error) {
|
|
150
162
|
return { state: 'unknown', detail: `identity index response is unreadable (${msg(error)})` };
|
|
151
163
|
}
|
|
152
164
|
}
|
|
165
|
+
function classifyIdentityPresence(name, identities) {
|
|
166
|
+
if (!Array.isArray(identities))
|
|
167
|
+
return { state: 'unknown', detail: 'identity index response is malformed' };
|
|
168
|
+
// A healthy daemon normally has at least its Human identity. During daemon
|
|
169
|
+
// restart, however, the authenticated endpoint can briefly serve a valid
|
|
170
|
+
// but empty index while state is still loading. Empty is therefore not
|
|
171
|
+
// enough authority to retire a live temporary role.
|
|
172
|
+
if (identities.length === 0)
|
|
173
|
+
return { state: 'unknown', detail: 'identity index is temporarily empty' };
|
|
174
|
+
const found = identities.find(identity => (typeof identity === 'string'
|
|
175
|
+
? identity
|
|
176
|
+
: identity && typeof identity === 'object'
|
|
177
|
+
? identity.name
|
|
178
|
+
: undefined) === name);
|
|
179
|
+
if (!found)
|
|
180
|
+
return { state: 'absent' };
|
|
181
|
+
return typeof found === 'string'
|
|
182
|
+
? { state: 'present', temporary: false, stale: false }
|
|
183
|
+
: {
|
|
184
|
+
state: 'present',
|
|
185
|
+
temporary: found.temporary === true,
|
|
186
|
+
stale: found.stale === true,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
153
189
|
/** Actionable, secret-free description of every token source for this profile. */
|
|
154
190
|
export function authResolutionHint(ep) {
|
|
155
191
|
const tokenPath = join(ep.stateDir, 'daemon-token');
|
|
@@ -379,6 +415,10 @@ export class Monitor {
|
|
|
379
415
|
cfg;
|
|
380
416
|
deps;
|
|
381
417
|
ep;
|
|
418
|
+
profile;
|
|
419
|
+
profileKey;
|
|
420
|
+
monitorLeaseToken = `ours-fleet-monitor-${process.pid}-${randomUUID()}`;
|
|
421
|
+
profileClientPromise;
|
|
382
422
|
statusPath;
|
|
383
423
|
cursorPath;
|
|
384
424
|
statePath;
|
|
@@ -400,7 +440,9 @@ export class Monitor {
|
|
|
400
440
|
this.identity = o.identity ?? o.name;
|
|
401
441
|
this.cfg = o.cfg;
|
|
402
442
|
this.deps = o.deps;
|
|
403
|
-
this.
|
|
443
|
+
this.profile = readClientProfile(o.deps.env);
|
|
444
|
+
this.ep = this.profile ? undefined : resolveEndpoint(o.deps.env);
|
|
445
|
+
this.profileKey = this.profile ? clientProfileKey(this.profile) : this.ep.origin;
|
|
404
446
|
this.statusPath = join(o.agentDir, '.monitor-status');
|
|
405
447
|
this.cursorPath = join(o.agentDir, '.notify-cursor');
|
|
406
448
|
this.statePath = join(o.agentDir, '.monitor-state.json');
|
|
@@ -428,8 +470,14 @@ export class Monitor {
|
|
|
428
470
|
}
|
|
429
471
|
catch (e) {
|
|
430
472
|
if (e instanceof AuthError) {
|
|
431
|
-
this.
|
|
432
|
-
|
|
473
|
+
if (this.profile) {
|
|
474
|
+
this.cursor = null;
|
|
475
|
+
this.degrade('auth', e.message);
|
|
476
|
+
}
|
|
477
|
+
else {
|
|
478
|
+
this.fatal = true;
|
|
479
|
+
this.degrade('auth', e.message, 'failed');
|
|
480
|
+
}
|
|
433
481
|
}
|
|
434
482
|
else {
|
|
435
483
|
this.cursor = null;
|
|
@@ -439,70 +487,81 @@ export class Monitor {
|
|
|
439
487
|
}
|
|
440
488
|
/** Long-poll → filter → coalesce → inject, until the pane pid dies or stop(). */
|
|
441
489
|
async run(pid) {
|
|
442
|
-
if (this.fatal)
|
|
490
|
+
if (this.fatal) {
|
|
491
|
+
await this.disposeProfileClient();
|
|
443
492
|
return;
|
|
493
|
+
}
|
|
444
494
|
this.bootDeadline = this.deps.now() + BOOT_GRACE_MS;
|
|
445
495
|
let backoff = 0;
|
|
446
496
|
const pending = [];
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
this.
|
|
450
|
-
|
|
451
|
-
}
|
|
452
|
-
let body;
|
|
453
|
-
try {
|
|
454
|
-
body = await this.doFetch(String(this.cursor ?? 0), LONGPOLL_STALL_MS, 'stall');
|
|
455
|
-
backoff = 0;
|
|
456
|
-
// A poll that worked proves the stream is healthy — and only that.
|
|
457
|
-
this.recover('connectivity');
|
|
458
|
-
}
|
|
459
|
-
catch (e) {
|
|
460
|
-
if (this.stopped)
|
|
461
|
-
return;
|
|
462
|
-
if (e instanceof AuthError) {
|
|
463
|
-
this.fatal = true;
|
|
464
|
-
this.degrade('auth', e.message, 'failed');
|
|
497
|
+
try {
|
|
498
|
+
while (!this.stopped) {
|
|
499
|
+
if (!this.deps.isAlive(pid)) {
|
|
500
|
+
this.degrade('offline', 'session offline');
|
|
465
501
|
return;
|
|
466
502
|
}
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
503
|
+
let body;
|
|
504
|
+
try {
|
|
505
|
+
body = await this.doFetch(String(this.cursor ?? 0), LONGPOLL_STALL_MS, 'stall');
|
|
506
|
+
backoff = 0;
|
|
507
|
+
// A poll that worked proves the stream is healthy — and only that.
|
|
508
|
+
this.recover('connectivity', 'auth');
|
|
509
|
+
}
|
|
510
|
+
catch (e) {
|
|
511
|
+
if (this.stopped)
|
|
512
|
+
return;
|
|
513
|
+
if (e instanceof AuthError && !this.profile) {
|
|
514
|
+
this.fatal = true;
|
|
515
|
+
this.degrade('auth', e.message, 'failed');
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
backoff = Math.min(backoff + BACKOFF_STEP_MS, BACKOFF_MAX_MS);
|
|
519
|
+
if (e instanceof AuthError)
|
|
520
|
+
this.degrade('auth', e.message);
|
|
521
|
+
else
|
|
522
|
+
this.degrade('connectivity', `stream hiccup (${msg(e)})`);
|
|
523
|
+
await this.deps.sleep(backoff);
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
this.advance(body.cursor, false);
|
|
527
|
+
const batch = filterEvents(body.events ?? [], this.cfg.wake_sources);
|
|
528
|
+
appendUniqueEvents(pending, batch);
|
|
529
|
+
if (pending.length === 0) {
|
|
530
|
+
this.persistCursor();
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
this.pendingState = {
|
|
534
|
+
count: pending.length,
|
|
535
|
+
eventTypes: uniq(pending.map(event => event.event ?? 'unknown')),
|
|
536
|
+
attempts: (this.pendingState?.attempts ?? 0) + 1,
|
|
537
|
+
};
|
|
538
|
+
this.persistState();
|
|
539
|
+
await this.coalesce(pending);
|
|
540
|
+
// Do not durably commit this cursor until the session explicitly accepts
|
|
541
|
+
// the wake. If delivery fails or the runner crashes, the daemon replays
|
|
542
|
+
// from the last committed cursor and the wake is attempted again.
|
|
543
|
+
let accepted = false;
|
|
544
|
+
try {
|
|
545
|
+
accepted = await this.deliver(pid, pending);
|
|
546
|
+
}
|
|
547
|
+
catch (e) {
|
|
548
|
+
this.degrade('delivery', `delivery failed (${msg(e)})`);
|
|
549
|
+
}
|
|
550
|
+
if (accepted) {
|
|
551
|
+
pending.length = 0;
|
|
552
|
+
this.pendingState = null;
|
|
553
|
+
this.persistCursor();
|
|
554
|
+
}
|
|
500
555
|
}
|
|
501
556
|
}
|
|
557
|
+
finally {
|
|
558
|
+
await this.disposeProfileClient();
|
|
559
|
+
}
|
|
502
560
|
}
|
|
503
561
|
stop() {
|
|
504
562
|
this.stopped = true;
|
|
505
563
|
this.currentAbort?.abort();
|
|
564
|
+
void this.disposeProfileClient();
|
|
506
565
|
}
|
|
507
566
|
// ── internals ──────────────────────────────────────────────────────────────
|
|
508
567
|
/** Gather stragglers arriving within batch_ms so a burst lands as one line. */
|
|
@@ -573,11 +632,24 @@ export class Monitor {
|
|
|
573
632
|
const timer = this.deps.timers.set(() => { timedOut = true; ctrl.abort(); }, timeoutMs);
|
|
574
633
|
let resp;
|
|
575
634
|
try {
|
|
635
|
+
if (this.profile) {
|
|
636
|
+
const client = await this.profileClient();
|
|
637
|
+
const page = await client.readNotificationPage(this.identity, {
|
|
638
|
+
since: since === 'tip' ? 'tip' : Number.parseInt(since, 10),
|
|
639
|
+
signal: ctrl.signal,
|
|
640
|
+
requestTimeoutMs: timeoutMs,
|
|
641
|
+
});
|
|
642
|
+
return { cursor: page.cursor, events: page.events };
|
|
643
|
+
}
|
|
576
644
|
resp = await this.deps.fetch(`${this.ep.url(this.identity)}?since=${since}`, { headers: this.ep.headers, signal: ctrl.signal });
|
|
577
645
|
}
|
|
578
646
|
catch (error) {
|
|
579
647
|
if (timedOut && timeoutKind === 'stall')
|
|
580
648
|
throw new Error(`notification stream stalled for ${Math.round(timeoutMs / 1000)}s`);
|
|
649
|
+
if (this.profile && /(?:HTTP\s*401|unauthori[sz]ed|API token)/i.test(msg(error)))
|
|
650
|
+
throw new AuthError(`daemon rejected the API token (401) — verify the credential file `
|
|
651
|
+
+ `${JSON.stringify(this.profile.credentialPath)} selected by `
|
|
652
|
+
+ `${JSON.stringify(this.profile.configPath)}`);
|
|
581
653
|
throw error;
|
|
582
654
|
}
|
|
583
655
|
finally {
|
|
@@ -590,6 +662,34 @@ export class Monitor {
|
|
|
590
662
|
throw new Error(`daemon returned HTTP ${resp.status}`);
|
|
591
663
|
return resp.json();
|
|
592
664
|
}
|
|
665
|
+
profileClient() {
|
|
666
|
+
if (!this.profile)
|
|
667
|
+
throw new Error('explicit client profile is not selected');
|
|
668
|
+
if (!this.profileClientPromise) {
|
|
669
|
+
const { endpoint, expectedInstanceId, credentialPath } = this.profile;
|
|
670
|
+
const options = {
|
|
671
|
+
endpoint, expectedInstanceId, credentialPath,
|
|
672
|
+
sessionMode: 'external', leaseToken: this.monitorLeaseToken, env: {},
|
|
673
|
+
};
|
|
674
|
+
this.profileClientPromise = Promise.resolve(this.deps.attachClient?.(options) ?? attachOursClient(options)).catch(error => {
|
|
675
|
+
this.profileClientPromise = undefined;
|
|
676
|
+
throw error;
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
return this.profileClientPromise;
|
|
680
|
+
}
|
|
681
|
+
async disposeProfileClient() {
|
|
682
|
+
const pending = this.profileClientPromise;
|
|
683
|
+
this.profileClientPromise = undefined;
|
|
684
|
+
if (!pending)
|
|
685
|
+
return;
|
|
686
|
+
try {
|
|
687
|
+
await (await pending).close();
|
|
688
|
+
}
|
|
689
|
+
catch {
|
|
690
|
+
this.deps.log(`[${this.name}] monitor: failed to close daemon client`);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
593
693
|
advance(cursor, persist = true) {
|
|
594
694
|
if (typeof cursor === 'number' && cursor !== this.cursor) {
|
|
595
695
|
this.cursor = cursor;
|
|
@@ -616,7 +716,7 @@ export class Monitor {
|
|
|
616
716
|
if (existsSync(this.statePath)) {
|
|
617
717
|
const state = JSON.parse(readFileSync(this.statePath, 'utf8'));
|
|
618
718
|
if ((state.identity !== undefined && state.identity !== this.identity)
|
|
619
|
-
|| (state.profileKey !== undefined && state.profileKey !== this.
|
|
719
|
+
|| (state.profileKey !== undefined && state.profileKey !== this.profileKey))
|
|
620
720
|
return null;
|
|
621
721
|
if (typeof state.deliveredCursor === 'number') {
|
|
622
722
|
this.deliveredCursor = state.deliveredCursor;
|
|
@@ -641,7 +741,7 @@ export class Monitor {
|
|
|
641
741
|
writeFileSync(tmp, JSON.stringify({
|
|
642
742
|
version: 1,
|
|
643
743
|
identity: this.identity,
|
|
644
|
-
profileKey: this.
|
|
744
|
+
profileKey: this.profileKey,
|
|
645
745
|
observedCursor: this.cursor,
|
|
646
746
|
deliveredCursor: this.deliveredCursor,
|
|
647
747
|
pending: this.pendingState,
|
|
@@ -14,6 +14,8 @@ export interface OwnerChannelOptions {
|
|
|
14
14
|
harness: string;
|
|
15
15
|
config: OwnerChannelConfig;
|
|
16
16
|
session: AgentSession;
|
|
17
|
+
/** Ordinary owner input must not cancel the initial briefing turn. */
|
|
18
|
+
startupPending?: () => boolean;
|
|
17
19
|
stateDir: string;
|
|
18
20
|
env?: Record<string, string>;
|
|
19
21
|
log(line: string): void;
|
|
@@ -269,6 +271,7 @@ export declare class OwnerChannel implements OwnerChannelHandle {
|
|
|
269
271
|
private attachmentGroups;
|
|
270
272
|
private handleAttachmentGroup;
|
|
271
273
|
private handle;
|
|
274
|
+
private ownerPromptPolicy;
|
|
272
275
|
/**
|
|
273
276
|
* Deterministic command path: the message never becomes an agent prompt.
|
|
274
277
|
* Authorization already happened — the managed-agent relay branch and the
|