@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.
- package/README.md +101 -25
- package/dist/application/fleet-query-service.js +12 -0
- package/dist/application/role-creation-service.js +3 -1
- package/dist/application/types.d.ts +11 -0
- package/dist/briefing.js +9 -2
- package/dist/build-info.json +7 -6
- package/dist/capabilities.d.ts +3 -1
- package/dist/capabilities.js +3 -0
- package/dist/cli.js +83 -7
- package/dist/config.d.ts +11 -3
- package/dist/config.js +40 -15
- package/dist/creation.d.ts +14 -15
- package/dist/creation.js +19 -13
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +113 -27
- package/dist/doctor.d.ts +1 -5
- package/dist/doctor.js +11 -18
- package/dist/fleet-proxy.d.ts +5 -0
- package/dist/harness/acp-agent.js +11 -6
- package/dist/harness/claude-code.js +204 -11
- package/dist/harness/codex.d.ts +4 -1
- package/dist/harness/codex.js +74 -12
- package/dist/harness/types.d.ts +54 -4
- package/dist/harness-plugins.d.ts +48 -0
- package/dist/harness-plugins.js +309 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/loops/manager.d.ts +30 -1
- package/dist/loops/manager.js +69 -6
- package/dist/loops/state.d.ts +18 -0
- package/dist/loops/state.js +4 -0
- package/dist/model-env.d.ts +71 -0
- package/dist/model-env.js +106 -0
- package/dist/monitor.js +1 -1
- package/dist/ops.js +1 -1
- package/dist/owner-channel/attachments.d.ts +2 -25
- package/dist/owner-channel/attachments.js +5 -61
- package/dist/owner-channel/channel.d.ts +28 -17
- package/dist/owner-channel/channel.js +249 -158
- package/dist/owner-channel/mcp.d.ts +24 -0
- package/dist/owner-channel/mcp.js +145 -0
- package/dist/owner-channel/notices.d.ts +7 -0
- package/dist/owner-channel/notices.js +9 -0
- package/dist/resolved-plan.js +1 -0
- package/dist/runner.d.ts +48 -0
- package/dist/runner.js +237 -85
- package/dist/session/acp.d.ts +104 -0
- package/dist/session/acp.js +213 -10
- package/dist/session/activity.d.ts +31 -0
- package/dist/session/activity.js +48 -0
- package/dist/session/conversation-normalizer.d.ts +6 -0
- package/dist/session/conversation-normalizer.js +153 -10
- package/dist/session/conversation-types.d.ts +23 -4
- package/dist/session/types.d.ts +35 -0
- package/dist/spawn.js +29 -17
- package/dist/supervisor/systemd.js +2 -29
- package/dist/watchdog/briefing.js +7 -0
- package/dist/web-app/assets/{TerminalView-BAVk1Bot.js → TerminalView-C_G1ID2P.js} +1 -1
- package/dist/web-app/assets/{index-C3S-xFRU.js → index-BCBK78hw.js} +5 -5
- package/dist/web-app/index.html +1 -1
- package/dist/worklog.d.ts +7 -1
- package/dist/worklog.js +191 -39
- package/package.json +1 -3
- package/dist/owner-channel/ours-client.d.ts +0 -141
- package/dist/owner-channel/ours-client.js +0 -225
package/dist/loops/state.js
CHANGED
|
@@ -100,6 +100,9 @@ export class ScheduledLoopStateStore {
|
|
|
100
100
|
if (old?.definitionHash === definition.definitionHash) {
|
|
101
101
|
next[definition.name] = {
|
|
102
102
|
...old, promptHash: definition.promptHash, enabled: definition.enabled,
|
|
103
|
+
// A file written before this field existed restores as undefined; an
|
|
104
|
+
// unreported gap is absent, not lost, so normalize rather than trust.
|
|
105
|
+
missedGap: old.missedGap ?? null,
|
|
103
106
|
};
|
|
104
107
|
}
|
|
105
108
|
else {
|
|
@@ -119,6 +122,7 @@ export class ScheduledLoopStateStore {
|
|
|
119
122
|
activeRunId: old?.activeRunId ?? null,
|
|
120
123
|
counts: old?.counts ?? zeroCounts(), lastError: old?.lastError ?? null,
|
|
121
124
|
operatorDisabled: old?.operatorDisabled ?? false,
|
|
125
|
+
missedGap: old?.missedGap ?? null,
|
|
122
126
|
};
|
|
123
127
|
}
|
|
124
128
|
if (recoverActive && next[definition.name].activeRunId) {
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { ResolvedRole } from './config.js';
|
|
2
|
+
/**
|
|
3
|
+
* Which environment variable a harness reads to pin the model it RUNS.
|
|
4
|
+
*
|
|
5
|
+
* This is not a convenience: for `claude-code` it is the only channel that
|
|
6
|
+
* reaches the ACP backend at all. `buildLaunch` (tmux) passes `--model`, but
|
|
7
|
+
* `buildAcpLaunch` launches the ACP adapter with no model argument, and that
|
|
8
|
+
* adapter resolves its model in this order — ANTHROPIC_MODEL, then
|
|
9
|
+
* `settings.model`, then a resumed session's live model, then its first
|
|
10
|
+
* catalogue entry. A role's declared model was therefore invisible to every
|
|
11
|
+
* ACP role, and a fleet-wide `defaults.env.ANTHROPIC_MODEL` silently outranked
|
|
12
|
+
* an explicitly requested one.
|
|
13
|
+
*/
|
|
14
|
+
export declare const MODEL_ENV_BY_HARNESS: Readonly<Record<string, string>>;
|
|
15
|
+
/** The model-pin variable for a harness, or undefined if it pins no model by env. */
|
|
16
|
+
export declare function modelEnvVar(harness: string | undefined): string | undefined;
|
|
17
|
+
export interface RoleModelEnvInput {
|
|
18
|
+
harness: string;
|
|
19
|
+
/** Already resolved by `resolveRoleModel` — may come from the fleet default. */
|
|
20
|
+
model: string | undefined;
|
|
21
|
+
/** True when the role (or `--model`) named a model, including `model: null`. */
|
|
22
|
+
modelWasExplicit: boolean;
|
|
23
|
+
defaultsEnv?: Record<string, string>;
|
|
24
|
+
roleEnv?: Record<string, string>;
|
|
25
|
+
authProxyBaseUrl?: string;
|
|
26
|
+
}
|
|
27
|
+
export interface RoleModelEnv {
|
|
28
|
+
env: Record<string, string>;
|
|
29
|
+
/**
|
|
30
|
+
* The model the harness will actually run. Equal to `env[pin]` for a harness
|
|
31
|
+
* that pins by env, so anything reporting this value reports the runtime.
|
|
32
|
+
*/
|
|
33
|
+
model: string | undefined;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Resolve a role's environment and its runtime model TOGETHER, so the two can
|
|
37
|
+
* never disagree.
|
|
38
|
+
*
|
|
39
|
+
* Precedence, highest first:
|
|
40
|
+
* 1. an explicit `model:` / `--model` on the role
|
|
41
|
+
* 2. the role's own `env:` pin
|
|
42
|
+
* 3. the fleet `defaults.model`
|
|
43
|
+
* 4. the fleet `defaults.env` pin
|
|
44
|
+
*
|
|
45
|
+
* Inheriting the fleet default remains correct when the role names no model
|
|
46
|
+
* (2, 3, 4); an explicitly named one wins (1). Where both are explicit and they
|
|
47
|
+
* disagree, there is no defensible winner, so this refuses rather than picking
|
|
48
|
+
* one silently — the silence is what let a day of "Fable" work run on Opus.
|
|
49
|
+
*
|
|
50
|
+
* `model: null` explicitly asks for no fleet-chosen model, so it also clears an
|
|
51
|
+
* inherited pin instead of leaving one in place to act as a hidden default.
|
|
52
|
+
*/
|
|
53
|
+
export declare function resolveRoleModelEnv(input: RoleModelEnvInput, describe?: (message: string) => Error): RoleModelEnv;
|
|
54
|
+
/**
|
|
55
|
+
* The model a role will actually run, read back from the environment it was
|
|
56
|
+
* resolved with. Use this wherever a model is reported to a human.
|
|
57
|
+
*/
|
|
58
|
+
export declare function effectiveRoleModel(role: ResolvedRole): string | undefined;
|
|
59
|
+
/**
|
|
60
|
+
* Move a role's env pin onto a new model. Anything that changes the model a
|
|
61
|
+
* role runs after resolution — model-chain recovery is the live example — must
|
|
62
|
+
* go through this, or it changes only the label.
|
|
63
|
+
*/
|
|
64
|
+
export declare function repinModelEnv(role: ResolvedRole, model: string | undefined): Record<string, string> | undefined;
|
|
65
|
+
/**
|
|
66
|
+
* Last line of defence, at the exact point a child's environment is composed:
|
|
67
|
+
* refuse to launch a role whose child would run a model other than the one the
|
|
68
|
+
* role declares and the banner reports. A spawn that cannot keep those two in
|
|
69
|
+
* agreement must fail loudly, not start and be believed.
|
|
70
|
+
*/
|
|
71
|
+
export declare function assertModelPinReachesChild(role: ResolvedRole, childEnv: Record<string, string | undefined>): void;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which environment variable a harness reads to pin the model it RUNS.
|
|
3
|
+
*
|
|
4
|
+
* This is not a convenience: for `claude-code` it is the only channel that
|
|
5
|
+
* reaches the ACP backend at all. `buildLaunch` (tmux) passes `--model`, but
|
|
6
|
+
* `buildAcpLaunch` launches the ACP adapter with no model argument, and that
|
|
7
|
+
* adapter resolves its model in this order — ANTHROPIC_MODEL, then
|
|
8
|
+
* `settings.model`, then a resumed session's live model, then its first
|
|
9
|
+
* catalogue entry. A role's declared model was therefore invisible to every
|
|
10
|
+
* ACP role, and a fleet-wide `defaults.env.ANTHROPIC_MODEL` silently outranked
|
|
11
|
+
* an explicitly requested one.
|
|
12
|
+
*/
|
|
13
|
+
export const MODEL_ENV_BY_HARNESS = {
|
|
14
|
+
'claude-code': 'ANTHROPIC_MODEL',
|
|
15
|
+
};
|
|
16
|
+
/** The model-pin variable for a harness, or undefined if it pins no model by env. */
|
|
17
|
+
export function modelEnvVar(harness) {
|
|
18
|
+
return harness === undefined ? undefined : MODEL_ENV_BY_HARNESS[harness];
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Resolve a role's environment and its runtime model TOGETHER, so the two can
|
|
22
|
+
* never disagree.
|
|
23
|
+
*
|
|
24
|
+
* Precedence, highest first:
|
|
25
|
+
* 1. an explicit `model:` / `--model` on the role
|
|
26
|
+
* 2. the role's own `env:` pin
|
|
27
|
+
* 3. the fleet `defaults.model`
|
|
28
|
+
* 4. the fleet `defaults.env` pin
|
|
29
|
+
*
|
|
30
|
+
* Inheriting the fleet default remains correct when the role names no model
|
|
31
|
+
* (2, 3, 4); an explicitly named one wins (1). Where both are explicit and they
|
|
32
|
+
* disagree, there is no defensible winner, so this refuses rather than picking
|
|
33
|
+
* one silently — the silence is what let a day of "Fable" work run on Opus.
|
|
34
|
+
*
|
|
35
|
+
* `model: null` explicitly asks for no fleet-chosen model, so it also clears an
|
|
36
|
+
* inherited pin instead of leaving one in place to act as a hidden default.
|
|
37
|
+
*/
|
|
38
|
+
export function resolveRoleModelEnv(input, describe = message => new Error(message)) {
|
|
39
|
+
const env = {
|
|
40
|
+
...(input.defaultsEnv ?? {}),
|
|
41
|
+
...(input.roleEnv ?? {}),
|
|
42
|
+
...(input.authProxyBaseUrl ? { ANTHROPIC_BASE_URL: input.authProxyBaseUrl } : {}),
|
|
43
|
+
};
|
|
44
|
+
const pin = modelEnvVar(input.harness);
|
|
45
|
+
if (!pin)
|
|
46
|
+
return { env, model: input.model };
|
|
47
|
+
const rolePin = input.roleEnv?.[pin];
|
|
48
|
+
if (input.modelWasExplicit) {
|
|
49
|
+
if (rolePin !== undefined && rolePin !== input.model)
|
|
50
|
+
throw describe(`model '${input.model ?? '(none)'}' contradicts env.${pin} '${rolePin}'; `
|
|
51
|
+
+ `remove one — ${pin} is what the harness actually runs`);
|
|
52
|
+
if (input.model === undefined)
|
|
53
|
+
delete env[pin];
|
|
54
|
+
else
|
|
55
|
+
env[pin] = input.model;
|
|
56
|
+
return { env, model: input.model };
|
|
57
|
+
}
|
|
58
|
+
// Not explicit: a role-level pin is the most specific thing said about this
|
|
59
|
+
// role, so it decides — and the reported model follows it.
|
|
60
|
+
if (rolePin !== undefined)
|
|
61
|
+
return { env, model: rolePin };
|
|
62
|
+
if (input.model !== undefined)
|
|
63
|
+
env[pin] = input.model;
|
|
64
|
+
return { env, model: input.model ?? env[pin] };
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The model a role will actually run, read back from the environment it was
|
|
68
|
+
* resolved with. Use this wherever a model is reported to a human.
|
|
69
|
+
*/
|
|
70
|
+
export function effectiveRoleModel(role) {
|
|
71
|
+
const pin = modelEnvVar(role.harness);
|
|
72
|
+
return (pin ? role.env?.[pin] : undefined) ?? role.model;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Move a role's env pin onto a new model. Anything that changes the model a
|
|
76
|
+
* role runs after resolution — model-chain recovery is the live example — must
|
|
77
|
+
* go through this, or it changes only the label.
|
|
78
|
+
*/
|
|
79
|
+
export function repinModelEnv(role, model) {
|
|
80
|
+
const pin = modelEnvVar(role.harness);
|
|
81
|
+
if (!pin)
|
|
82
|
+
return role.env;
|
|
83
|
+
const env = { ...(role.env ?? {}) };
|
|
84
|
+
if (model === undefined)
|
|
85
|
+
delete env[pin];
|
|
86
|
+
else
|
|
87
|
+
env[pin] = model;
|
|
88
|
+
return Object.keys(env).length ? env : undefined;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Last line of defence, at the exact point a child's environment is composed:
|
|
92
|
+
* refuse to launch a role whose child would run a model other than the one the
|
|
93
|
+
* role declares and the banner reports. A spawn that cannot keep those two in
|
|
94
|
+
* agreement must fail loudly, not start and be believed.
|
|
95
|
+
*/
|
|
96
|
+
export function assertModelPinReachesChild(role, childEnv) {
|
|
97
|
+
const pin = modelEnvVar(role.harness);
|
|
98
|
+
if (!pin || role.model === undefined)
|
|
99
|
+
return;
|
|
100
|
+
const actual = childEnv[pin];
|
|
101
|
+
if (actual === role.model)
|
|
102
|
+
return;
|
|
103
|
+
throw new Error(`[${role.name}] refusing to launch: role model is '${role.model}' but the child's `
|
|
104
|
+
+ `${pin} is ${actual === undefined ? 'unset' : `'${actual}'`} — the session would run a `
|
|
105
|
+
+ 'different model than the one reported');
|
|
106
|
+
}
|
package/dist/monitor.js
CHANGED
|
@@ -42,7 +42,7 @@ class AuthError extends Error {
|
|
|
42
42
|
}
|
|
43
43
|
/** Path to the daemon config the MCP client uses: OURS_CONFIG ?? real ~/.ours/config.json. */
|
|
44
44
|
const daemonConfigPath = (env) => env.OURS_CONFIG ?? join(homedir(), '.ours', 'config.json');
|
|
45
|
-
/**
|
|
45
|
+
/** Match ours-mcp's env integer semantics: parseInt, invalid → absent. */
|
|
46
46
|
function envInt(env, name) {
|
|
47
47
|
const raw = env[name];
|
|
48
48
|
if (raw === undefined)
|
package/dist/ops.js
CHANGED
|
@@ -16,7 +16,7 @@ import { realExec } from './exec.js';
|
|
|
16
16
|
/** Materialize a role's state dir from config: briefing + markers. Returns the dir. */
|
|
17
17
|
export function applyRole(role, opts = {}) {
|
|
18
18
|
const adapter = getAdapter(role.harness);
|
|
19
|
-
const errs = adapter.validateOptions(role.harness_options);
|
|
19
|
+
const errs = adapter.validateOptions(role.harness_options, role);
|
|
20
20
|
if (errs.length)
|
|
21
21
|
throw new Error(`role '${role.name}': ` + errs.map(e => `${e.path}: ${e.message}`).join('; '));
|
|
22
22
|
const dir = agentDir(role.name, opts.temp === true);
|
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
import { type FileHandle } from 'node:fs/promises';
|
|
2
1
|
import type { OwnerAttachmentConfig } from '../config.js';
|
|
3
|
-
import type { OursIncomingFile, OursRetrievedFiles } from './ours-client.js';
|
|
4
2
|
export interface AttachmentReplyRef {
|
|
5
3
|
wire_id: string;
|
|
6
4
|
sentence?: number;
|
|
@@ -44,14 +42,8 @@ export interface AdmittedAttachment {
|
|
|
44
42
|
kind: 'file' | 'voice_message';
|
|
45
43
|
transcription?: Omit<VoiceTranscription, 'audioPath'>;
|
|
46
44
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
* still re-validated here: sender CID, wire id, sizes and ids all cross the
|
|
50
|
-
* trust boundary and decide routing, so a daemon-side shape change must drop a
|
|
51
|
-
* row rather than produce a half-built attachment.
|
|
52
|
-
*/
|
|
53
|
-
export declare function parseIncomingAttachments(raw: OursIncomingFile[] | undefined): IncomingAttachment[];
|
|
54
|
-
export declare function parseRetrievedAttachments(raw: OursRetrievedFiles | undefined, expected: IncomingAttachment[], recovered?: boolean): RetrievedAttachment[];
|
|
45
|
+
export declare function parseIncomingAttachments(raw: unknown): IncomingAttachment[];
|
|
46
|
+
export declare function parseRetrievedAttachments(raw: unknown, expected: IncomingAttachment[], recovered?: boolean): RetrievedAttachment[];
|
|
55
47
|
export declare function validateAttachmentSelection(files: IncomingAttachment[], config: OwnerAttachmentConfig): string | undefined;
|
|
56
48
|
/**
|
|
57
49
|
* Managed-agent -> owner egress limits. This intentionally does not consult
|
|
@@ -62,21 +54,6 @@ export declare function prepareAttachmentDirectory(root: string, requestId: stri
|
|
|
62
54
|
export declare function admitAttachments(files: RetrievedAttachment[], dir: string, config: OwnerAttachmentConfig, options?: {
|
|
63
55
|
mimePolicy?: 'strict' | 'report-only';
|
|
64
56
|
}): Promise<AdmittedAttachment[]>;
|
|
65
|
-
/** Injectable short-write seam, so partial writes are provably handled. */
|
|
66
|
-
export interface AttachmentWriteDeps {
|
|
67
|
-
write?(handle: FileHandle, bytes: Uint8Array, offset: number): Promise<number>;
|
|
68
|
-
}
|
|
69
|
-
/**
|
|
70
|
-
* Land crash-recovered file bytes inside an already-prepared request directory.
|
|
71
|
-
*
|
|
72
|
-
* The MCP path handed the daemon a `dest_path` and let its connector write the
|
|
73
|
-
* file. Nothing writes on our behalf any more, so this owns both halves of that
|
|
74
|
-
* contract: the destination is DERIVED from a validated wire id inside `dir`
|
|
75
|
-
* rather than accepted from a caller, and the file is published by link-after-
|
|
76
|
-
* fsync, so a crash or a short write can never leave a partial file where the
|
|
77
|
-
* admission step would read it as complete.
|
|
78
|
-
*/
|
|
79
|
-
export declare function writeRecoveredAttachment(dir: string, wireId: string, bytes: Uint8Array, deps?: AttachmentWriteDeps): Promise<string>;
|
|
80
57
|
export declare function recoveredAttachment(file: IncomingAttachment, path: string): Promise<RetrievedAttachment>;
|
|
81
58
|
export declare function removeRequestDirectory(path: string): Promise<void>;
|
|
82
59
|
export declare function cleanupAttachmentRoot(root: string, now: number, retentionMs: number, limit?: number): Promise<number>;
|
|
@@ -6,14 +6,8 @@ import { replaceFileAtomically } from '../atomic-file.js';
|
|
|
6
6
|
const WIRE = /^[A-Fa-f0-9]{64}$/;
|
|
7
7
|
const CID = /^[A-Fa-f0-9]{64}$/;
|
|
8
8
|
const MAX_PENDING_REQUESTS = 32;
|
|
9
|
-
/**
|
|
10
|
-
* Admit the daemon's file listing. The rows are typed now, but every field is
|
|
11
|
-
* still re-validated here: sender CID, wire id, sizes and ids all cross the
|
|
12
|
-
* trust boundary and decide routing, so a daemon-side shape change must drop a
|
|
13
|
-
* row rather than produce a half-built attachment.
|
|
14
|
-
*/
|
|
15
9
|
export function parseIncomingAttachments(raw) {
|
|
16
|
-
const values = raw;
|
|
10
|
+
const values = raw?.files;
|
|
17
11
|
if (!Array.isArray(values))
|
|
18
12
|
return [];
|
|
19
13
|
const out = [];
|
|
@@ -46,12 +40,12 @@ export function parseIncomingAttachments(raw) {
|
|
|
46
40
|
export function parseRetrievedAttachments(raw, expected, recovered = false) {
|
|
47
41
|
const values = raw?.files;
|
|
48
42
|
if (!Array.isArray(values) || values.length !== expected.length)
|
|
49
|
-
throw new Error('
|
|
43
|
+
throw new Error('ours-mcp returned an incomplete selected attachment set');
|
|
50
44
|
const byWire = new Map(expected.map(file => [file.wireId, file]));
|
|
51
45
|
const out = [];
|
|
52
46
|
for (const value of values) {
|
|
53
47
|
if (!value || typeof value !== 'object')
|
|
54
|
-
throw new Error('
|
|
48
|
+
throw new Error('ours-mcp returned invalid attachment metadata');
|
|
55
49
|
const file = value;
|
|
56
50
|
const wireId = String(file.wire_id ?? '');
|
|
57
51
|
const listed = byWire.get(wireId);
|
|
@@ -64,7 +58,7 @@ export function parseRetrievedAttachments(raw, expected, recovered = false) {
|
|
|
64
58
|
|| !Number.isSafeInteger(size) || size !== listed.size || mime !== listed.mime
|
|
65
59
|
|| kind !== listed.kind || !/^[a-f0-9]{64}$/.test(sha256)
|
|
66
60
|
|| typeof file.path !== 'string' || !file.path)
|
|
67
|
-
throw new Error('selected attachment provenance or integrity metadata mismatched');
|
|
61
|
+
throw new Error('ours-mcp selected attachment provenance or integrity metadata mismatched');
|
|
68
62
|
out.push({
|
|
69
63
|
...listed, filename: safeField(file.filename, 255), mime, size, path: file.path, sha256, kind,
|
|
70
64
|
...(recovered ? {} : parseTranscription(file.transcription, wireId)),
|
|
@@ -72,7 +66,7 @@ export function parseRetrievedAttachments(raw, expected, recovered = false) {
|
|
|
72
66
|
byWire.delete(wireId);
|
|
73
67
|
}
|
|
74
68
|
if (byWire.size)
|
|
75
|
-
throw new Error('
|
|
69
|
+
throw new Error('ours-mcp omitted a selected attachment');
|
|
76
70
|
return out;
|
|
77
71
|
}
|
|
78
72
|
function parseTranscription(value, wireId) {
|
|
@@ -225,56 +219,6 @@ export async function admitAttachments(files, dir, config, options = {}) {
|
|
|
225
219
|
}
|
|
226
220
|
return admitted;
|
|
227
221
|
}
|
|
228
|
-
/**
|
|
229
|
-
* Land crash-recovered file bytes inside an already-prepared request directory.
|
|
230
|
-
*
|
|
231
|
-
* The MCP path handed the daemon a `dest_path` and let its connector write the
|
|
232
|
-
* file. Nothing writes on our behalf any more, so this owns both halves of that
|
|
233
|
-
* contract: the destination is DERIVED from a validated wire id inside `dir`
|
|
234
|
-
* rather than accepted from a caller, and the file is published by link-after-
|
|
235
|
-
* fsync, so a crash or a short write can never leave a partial file where the
|
|
236
|
-
* admission step would read it as complete.
|
|
237
|
-
*/
|
|
238
|
-
export async function writeRecoveredAttachment(dir, wireId, bytes, deps = {}) {
|
|
239
|
-
if (!WIRE.test(wireId))
|
|
240
|
-
throw new Error('recovered attachment wire id is not a 64-hex value');
|
|
241
|
-
const dirStat = await lstat(dir);
|
|
242
|
-
if (!dirStat.isDirectory() || dirStat.isSymbolicLink())
|
|
243
|
-
throw new Error('recovered attachment directory is not a safe directory');
|
|
244
|
-
const write = deps.write
|
|
245
|
-
?? ((handle, buffer, offset) => handle.write(buffer, offset, buffer.length - offset)
|
|
246
|
-
.then(result => result.bytesWritten));
|
|
247
|
-
const finalPath = join(dir, `.recovered-${wireId}-${randomUUID()}`);
|
|
248
|
-
const tmp = join(dir, `.${basename(finalPath)}.${randomUUID()}.tmp`);
|
|
249
|
-
const handle = await open(tmp, 'wx', 0o600);
|
|
250
|
-
try {
|
|
251
|
-
for (let written = 0; written < bytes.length;) {
|
|
252
|
-
const advanced = await write(handle, bytes, written);
|
|
253
|
-
if (advanced <= 0)
|
|
254
|
-
throw new Error(`recovered attachment write made no progress at byte ${written}`);
|
|
255
|
-
written += advanced;
|
|
256
|
-
}
|
|
257
|
-
await handle.sync();
|
|
258
|
-
}
|
|
259
|
-
catch (error) {
|
|
260
|
-
await handle.close().catch(() => undefined);
|
|
261
|
-
await rm(tmp, { force: true });
|
|
262
|
-
throw error;
|
|
263
|
-
}
|
|
264
|
-
await handle.close();
|
|
265
|
-
// link publishes the finished bytes under a name that never existed in a
|
|
266
|
-
// partial state; the temp is only ever removed after it succeeded.
|
|
267
|
-
try {
|
|
268
|
-
await link(tmp, finalPath);
|
|
269
|
-
}
|
|
270
|
-
catch (error) {
|
|
271
|
-
await rm(tmp, { force: true });
|
|
272
|
-
throw error;
|
|
273
|
-
}
|
|
274
|
-
await rm(tmp, { force: true });
|
|
275
|
-
await chmod(finalPath, 0o600);
|
|
276
|
-
return finalPath;
|
|
277
|
-
}
|
|
278
222
|
export async function recoveredAttachment(file, path) {
|
|
279
223
|
const stat = await lstat(path);
|
|
280
224
|
if (!stat.isFile() || stat.isSymbolicLink() || stat.size !== file.size)
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { type ChildProcessWithoutNullStreams } from 'node:child_process';
|
|
1
2
|
import { type OwnerChannelConfig } from '../config.js';
|
|
3
|
+
import { type FetchLike } from '../monitor.js';
|
|
2
4
|
import { type SessionHandle } from '../session/types.js';
|
|
3
5
|
import { type OwnerFleetOps } from './commands.js';
|
|
4
6
|
import type { ManagedFleetSpawnResult } from '../fleet-proxy.js';
|
|
5
|
-
import { type
|
|
7
|
+
import { type OursToolClient } from './mcp.js';
|
|
6
8
|
import { type OwnerUpdatePhase } from './notices.js';
|
|
7
9
|
import { type OwnerEntry } from './state.js';
|
|
8
10
|
import { type OwnerTaskPhase } from './tasks.js';
|
|
@@ -15,8 +17,15 @@ export interface OwnerChannelOptions {
|
|
|
15
17
|
session: SessionHandle;
|
|
16
18
|
stateDir: string;
|
|
17
19
|
env?: Record<string, string>;
|
|
20
|
+
command?: string;
|
|
18
21
|
log(line: string): void;
|
|
19
|
-
client?:
|
|
22
|
+
client?: OursToolClient;
|
|
23
|
+
/** Legacy child-process test seam; production uses the direct notification API. */
|
|
24
|
+
watch?: (identity: string) => ChildProcessWithoutNullStreams;
|
|
25
|
+
/** Test seam for the production direct notification long-poll. */
|
|
26
|
+
watchFetch?: FetchLike;
|
|
27
|
+
/** Test seam for the long-poll stall bound; production uses OWNER_WATCH_STALL_MS. */
|
|
28
|
+
watchStallMs?: number;
|
|
20
29
|
/** Test seam; production uses the detached ours-fleet CLI (`fleetCliOps`). */
|
|
21
30
|
fleet?: OwnerFleetOps;
|
|
22
31
|
/** Forwarded to fleet CLI invocations spawned for owner commands. */
|
|
@@ -109,13 +118,7 @@ export type { OwnerUpdatePhase } from './notices.js';
|
|
|
109
118
|
export interface OwnerContact {
|
|
110
119
|
cid: string;
|
|
111
120
|
name: string;
|
|
112
|
-
/** Structural, from which daemon collection the row came: established or pending. */
|
|
113
121
|
status: string;
|
|
114
|
-
/**
|
|
115
|
-
* Retained for the `ours-fleet owner contact list` column. The daemon's typed
|
|
116
|
-
* contact view has no such field, so it is always absent; it is not inferred
|
|
117
|
-
* from anything a contact controls.
|
|
118
|
-
*/
|
|
119
122
|
kind?: string;
|
|
120
123
|
human?: {
|
|
121
124
|
cid?: string;
|
|
@@ -152,6 +155,7 @@ export declare class OwnerChannel implements OwnerChannelHandle {
|
|
|
152
155
|
private readonly commentsBaseline;
|
|
153
156
|
private commentsEnabled;
|
|
154
157
|
private stopping;
|
|
158
|
+
private watchProcess?;
|
|
155
159
|
private watchTask?;
|
|
156
160
|
private watchAbort?;
|
|
157
161
|
private drainTask?;
|
|
@@ -170,12 +174,6 @@ export declare class OwnerChannel implements OwnerChannelHandle {
|
|
|
170
174
|
manage(request: OwnerChannelManagementRequest): Promise<OwnerChannelManagementResult>;
|
|
171
175
|
notifyFleetSpawn(event: ManagedFleetSpawnResult): Promise<void>;
|
|
172
176
|
private manageNow;
|
|
173
|
-
/**
|
|
174
|
-
* The daemon reports established contacts and pending introductions as two
|
|
175
|
-
* separate collections, so the status is structural rather than a word parsed
|
|
176
|
-
* out of a rendered line. Nothing here can be spoofed by a contact's own
|
|
177
|
-
* display name.
|
|
178
|
-
*/
|
|
179
177
|
private contacts;
|
|
180
178
|
private contact;
|
|
181
179
|
private assertCid;
|
|
@@ -240,6 +238,16 @@ export declare class OwnerChannel implements OwnerChannelHandle {
|
|
|
240
238
|
private warnOwnerOfUnauthorizedSender;
|
|
241
239
|
private effectiveOwners;
|
|
242
240
|
private authorizationIntegrity;
|
|
241
|
+
/**
|
|
242
|
+
* Report what the session actually did with the prompt, not what the config
|
|
243
|
+
* asked for. `interrupt: true` used to be reported as "your request
|
|
244
|
+
* interrupted the previous task" unconditionally; the session now answers
|
|
245
|
+
* whether anything was cancelled, whether the request is queued behind
|
|
246
|
+
* earlier prompts, or whether it is held until the current task reaches a
|
|
247
|
+
* safe stopping point. Backends that report no delivery state keep the old
|
|
248
|
+
* queuedBehind-based wording.
|
|
249
|
+
*/
|
|
250
|
+
private acceptanceNotice;
|
|
243
251
|
private complete;
|
|
244
252
|
private commentsState;
|
|
245
253
|
/** Model-authored commentary only; raw protocol/tool data never reaches here. */
|
|
@@ -259,12 +267,15 @@ export declare class OwnerChannel implements OwnerChannelHandle {
|
|
|
259
267
|
private progressPhase;
|
|
260
268
|
private watchLoop;
|
|
261
269
|
/**
|
|
262
|
-
* `recovered` distinguishes a first-ever start from
|
|
263
|
-
*
|
|
264
|
-
*
|
|
270
|
+
* `recovered` distinguishes a first-ever start (no state, nothing lost) from a
|
|
271
|
+
* cursor we HAD and can no longer read. Only the latter is a recovery, and the
|
|
272
|
+
* caller needs to know because the reason it reports is the only evidence a
|
|
273
|
+
* durable cursor was ever lost.
|
|
265
274
|
*/
|
|
266
275
|
private readWatchState;
|
|
267
276
|
private writeWatchState;
|
|
277
|
+
/** Compatibility path for injected child-process tests; production is direct. */
|
|
278
|
+
private legacyWatchLoop;
|
|
268
279
|
private errorText;
|
|
269
280
|
private logError;
|
|
270
281
|
}
|