petbox-wire 0.1.0-ci.1206
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 +119 -0
- package/bin/petbox-wire.js +30 -0
- package/package.json +44 -0
- package/src/agent-def-fetch.test.ts +317 -0
- package/src/agent-def-fetch.ts +409 -0
- package/src/agent-definition.ts +210 -0
- package/src/append-meta.test.ts +93 -0
- package/src/append.ts +172 -0
- package/src/apply-artifacts.ts +337 -0
- package/src/apply-root.test.ts +148 -0
- package/src/apply-root.ts +68 -0
- package/src/apply-write.test.ts +169 -0
- package/src/apply-write.ts +84 -0
- package/src/canon.ts +140 -0
- package/src/doctor-definition.test.ts +128 -0
- package/src/droid-pull-memory.ts +126 -0
- package/src/droid-push-session.ts +100 -0
- package/src/droid-transcript.ts +47 -0
- package/src/harness-capabilities.ts +126 -0
- package/src/harness-models.ts +158 -0
- package/src/hook-drain.ts +42 -0
- package/src/hook-prune.test.ts +165 -0
- package/src/hook-prune.ts +83 -0
- package/src/import-sessions.ts +261 -0
- package/src/opencode-plugin.ts +127 -0
- package/src/origin-marker.ts +37 -0
- package/src/posix-env.test.ts +99 -0
- package/src/posix-env.ts +61 -0
- package/src/protocol.test.ts +236 -0
- package/src/protocol.ts +136 -0
- package/src/pull-memory.test.ts +168 -0
- package/src/pull-memory.ts +119 -0
- package/src/push-session.ts +99 -0
- package/src/registry.ts +120 -0
- package/src/roles.test.ts +255 -0
- package/src/roles.ts +241 -0
- package/src/self-smoke.test.ts +103 -0
- package/src/self-smoke.ts +96 -0
- package/src/telemetry-settings.test.ts +65 -0
- package/src/telemetry-settings.ts +55 -0
- package/src/templates/SKILL.md +45 -0
- package/src/templates/agent-factory/SKILL.md +56 -0
- package/src/transcript.ts +86 -0
- package/src/truthfulness.test.ts +544 -0
- package/src/truthfulness.ts +164 -0
- package/src/wire-exit.test.ts +43 -0
- package/src/wire-exit.ts +25 -0
- package/src/wire-identity.test.ts +65 -0
- package/src/wire-identity.ts +64 -0
- package/src/wire.ts +1384 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Integration test for doctor gating the SAME definition apply would compile
|
|
2
|
+
// (bug: doctor-gates-wrong-definition).
|
|
3
|
+
//
|
|
4
|
+
// wire.ts runs main() at import time (see its own comment on why testable logic lives in side
|
|
5
|
+
// modules), so the only way to exercise `doctor`'s actual argv/behavior end-to-end is to spawn
|
|
6
|
+
// it as a real subprocess — same technique CI itself uses (`node src/wire.ts <args>`). This test
|
|
7
|
+
// seeds a fake ~/.petbox (via USERPROFILE/HOME redirection) with:
|
|
8
|
+
// - a registry entry for a throwaway project directory (identity)
|
|
9
|
+
// - an API key in the key store for that project's env var (resolveProject requires one)
|
|
10
|
+
// - an LKG agent-definition cache carrying a definition whose name is NOT "default" (the
|
|
11
|
+
// built-in DEFAULT_AGENT_DEFINITION.name), so the two are trivially distinguishable
|
|
12
|
+
//
|
|
13
|
+
// Before the fix, `doctor` ignored all of this and always gated the hard-coded
|
|
14
|
+
// DEFAULT_AGENT_DEFINITION (name "default") — so its output would show `definition="default"`
|
|
15
|
+
// even with a differently-named LKG cache sitting right there, identical to what `apply
|
|
16
|
+
// --offline` would use. After the fix, `doctor --offline` must report the LKG-cached name.
|
|
17
|
+
//
|
|
18
|
+
// Run: node --test src/doctor-definition.test.ts
|
|
19
|
+
|
|
20
|
+
import assert from "node:assert/strict";
|
|
21
|
+
import { spawnSync } from "node:child_process";
|
|
22
|
+
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
23
|
+
import { tmpdir } from "node:os";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
import { test } from "node:test";
|
|
26
|
+
|
|
27
|
+
const WIRE_TS = join(import.meta.dirname, "wire.ts");
|
|
28
|
+
|
|
29
|
+
function freshDir(prefix: string): string {
|
|
30
|
+
return realpathSync(mkdtempSync(join(tmpdir(), prefix)));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// A minimal but shape-valid AgentDefinition + cache envelope (agent-def-fetch.ts's
|
|
34
|
+
// AgentDefCacheRecord / parseAgentDefinitionResponse contract).
|
|
35
|
+
function makeCustomDefRecord(name: string) {
|
|
36
|
+
return {
|
|
37
|
+
key: "default",
|
|
38
|
+
version: 7,
|
|
39
|
+
fetchedAt: new Date().toISOString(),
|
|
40
|
+
definition: {
|
|
41
|
+
name,
|
|
42
|
+
roles: [
|
|
43
|
+
{
|
|
44
|
+
slug: "worker",
|
|
45
|
+
tier: "worker",
|
|
46
|
+
requiredCapabilities: [],
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function runDoctor(cwd: string, homeDir: string, extraArgs: string[] = []): { stdout: string; stderr: string; status: number | null } {
|
|
54
|
+
const res = spawnSync(process.execPath, [WIRE_TS, "doctor", ...extraArgs], {
|
|
55
|
+
cwd,
|
|
56
|
+
encoding: "utf8",
|
|
57
|
+
env: {
|
|
58
|
+
...process.env,
|
|
59
|
+
// Windows resolves homedir() from USERPROFILE; POSIX from HOME. Set both so the test is
|
|
60
|
+
// portable across the dev machine (win32) and any Linux CI runner.
|
|
61
|
+
USERPROFILE: homeDir,
|
|
62
|
+
HOME: homeDir,
|
|
63
|
+
HOMEDRIVE: undefined,
|
|
64
|
+
HOMEPATH: undefined,
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
return { stdout: res.stdout ?? "", stderr: res.stderr ?? "", status: res.status };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
test("doctor --offline gates the LKG-cached definition, not the hard-coded built-in default", () => {
|
|
71
|
+
const homeDir = freshDir("petbox-doctor-home-");
|
|
72
|
+
const projectDir = freshDir("petbox-doctor-proj-");
|
|
73
|
+
try {
|
|
74
|
+
const petboxDir = join(homeDir, ".petbox");
|
|
75
|
+
mkdirSync(petboxDir, { recursive: true });
|
|
76
|
+
mkdirSync(join(petboxDir, "cache"), { recursive: true });
|
|
77
|
+
|
|
78
|
+
const envVar = "PETBOX_DOCTOR_TEST_API_KEY";
|
|
79
|
+
writeFileSync(
|
|
80
|
+
join(petboxDir, "projects.json"),
|
|
81
|
+
JSON.stringify({ entries: [{ prefix: projectDir, project: "doctor-test-proj", envVar }] }, null, 2),
|
|
82
|
+
"utf8",
|
|
83
|
+
);
|
|
84
|
+
writeFileSync(join(petboxDir, "keys.json"), JSON.stringify({ [envVar]: "fake-key-value" }, null, 2), "utf8");
|
|
85
|
+
writeFileSync(
|
|
86
|
+
join(petboxDir, "cache", "doctor-test-proj.agent-def.json"),
|
|
87
|
+
JSON.stringify(makeCustomDefRecord("custom-lkg-def"), null, 2),
|
|
88
|
+
"utf8",
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const { stdout, stderr, status } = runDoctor(projectDir, homeDir, ["--offline"]);
|
|
92
|
+
const out = stdout + stderr;
|
|
93
|
+
|
|
94
|
+
assert.match(
|
|
95
|
+
out,
|
|
96
|
+
/definition="custom-lkg-def"/,
|
|
97
|
+
`doctor must gate the LKG-cached definition (custom-lkg-def), not the built-in default. Full output:\n${out}`,
|
|
98
|
+
);
|
|
99
|
+
assert.doesNotMatch(
|
|
100
|
+
out,
|
|
101
|
+
/definition="default"/,
|
|
102
|
+
`doctor must NOT fall back to the built-in DEFAULT_AGENT_DEFINITION when an LKG cache exists. Full output:\n${out}`,
|
|
103
|
+
);
|
|
104
|
+
assert.match(out, /using LKG definition/, "doctor must name which link it resolved (LKG here)");
|
|
105
|
+
// The custom def's single worker role has no requiredCapabilities, so every known harness
|
|
106
|
+
// passes trivially — doctor should exit clean.
|
|
107
|
+
assert.equal(status, 0);
|
|
108
|
+
} finally {
|
|
109
|
+
rmSync(homeDir, { recursive: true, force: true });
|
|
110
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("doctor --offline with NO registry entry / NO LKG cache falls back to the built-in default (unchanged behavior)", () => {
|
|
115
|
+
const homeDir = freshDir("petbox-doctor-home-");
|
|
116
|
+
const projectDir = freshDir("petbox-doctor-proj-");
|
|
117
|
+
try {
|
|
118
|
+
// No ~/.petbox at all — doctor must still work offline against the built-in default.
|
|
119
|
+
const { stdout, stderr, status } = runDoctor(projectDir, homeDir, ["--offline"]);
|
|
120
|
+
const out = stdout + stderr;
|
|
121
|
+
assert.match(out, /definition="default"/, `expected the built-in default when nothing is registered. Full output:\n${out}`);
|
|
122
|
+
assert.match(out, /offline default definition/);
|
|
123
|
+
assert.equal(status, 0);
|
|
124
|
+
} finally {
|
|
125
|
+
rmSync(homeDir, { recursive: true, force: true });
|
|
126
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
127
|
+
}
|
|
128
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Factory Droid SessionStart hook (global) — the droid port of pull-memory.ts.
|
|
2
|
+
//
|
|
3
|
+
// Injects the PetBox memory protocol + curated canon so the agent recalls relevant memory at
|
|
4
|
+
// session start and captures learnings as it works, via the already-connected petbox MCP.
|
|
5
|
+
//
|
|
6
|
+
// Droid names MCP tools `<server>___<tool>` (triple underscore) at runtime — observed live
|
|
7
|
+
// in exec mode; the docs' `mcp__<server>__<tool>` form did not match. The protocol renders
|
|
8
|
+
// with droidPetboxTool (`petbox___*`); the canon block stays byte-identical across agents. Droid's SessionStart stdin
|
|
9
|
+
// is snake_case (`session_id`, `transcript_path`, `cwd`, `source`), the same shape Claude Code
|
|
10
|
+
// uses, so we resolve the project from `cwd` and pass `source` through for the resume nudge.
|
|
11
|
+
//
|
|
12
|
+
// Output contract (docs): a SessionStart hook returns context to the model via the structured
|
|
13
|
+
// JSON `{ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext } }` on stdout
|
|
14
|
+
// (stdout-as-context is also accepted, but the structured form is the documented preference).
|
|
15
|
+
//
|
|
16
|
+
// Best-effort, always exit 0, no output for an unregistered cwd.
|
|
17
|
+
//
|
|
18
|
+
// The banner's orchestrator notes resolve server → LKG cache → built-in default, same as
|
|
19
|
+
// `apply` (resolveAgentDefinitionForSession, wrapping agent-def-fetch.ts's
|
|
20
|
+
// resolveAgentDefinitionWithLkg). That fetch and the canon fetch run SEQUENTIALLY under one
|
|
21
|
+
// shared SESSION_FETCH_BUDGET_MS wall-clock budget (not Promise.all'd) — verbatim the same
|
|
22
|
+
// reasoning as pull-memory.ts (this file's Claude Code counterpart): the happy path for both
|
|
23
|
+
// requests together is ~100-200ms, so concurrency bought nothing there, and it made the two
|
|
24
|
+
// independent 8s timeouts stack in the worst case if reasoned about naively. Giving the whole
|
|
25
|
+
// hook one budget means whatever the agent-def fetch doesn't spend, the canon fetch inherits
|
|
26
|
+
// as its own timeout, so the combined worst case stays ~SESSION_FETCH_BUDGET_MS, not 2x it. A
|
|
27
|
+
// fetch that starts with little/no budget left degrades to its own fallback (LKG cache /
|
|
28
|
+
// built-in default / no canon) rather than blocking — see canon.ts's fetchCanon.
|
|
29
|
+
|
|
30
|
+
import { resolveAgentDefinitionForSession } from "./agent-def-fetch.ts";
|
|
31
|
+
import { fetchCanonBlock } from "./canon.ts";
|
|
32
|
+
import { unrefLingeringHandles } from "./hook-drain.ts";
|
|
33
|
+
import { buildProtocol, droidPetboxTool } from "./protocol.ts";
|
|
34
|
+
import { resolveProject } from "./registry.ts";
|
|
35
|
+
|
|
36
|
+
// Shared wall-clock budget for BOTH fetches combined (agent-def, then canon) — see the
|
|
37
|
+
// module comment above. Kept in step with pull-memory.ts: short on purpose, because the LKG
|
|
38
|
+
// cache holds the same documents and a redeploy restarting the server must not tax every
|
|
39
|
+
// session start with an 8s stall. Stale-but-instant beats fresh-but-late here.
|
|
40
|
+
const SESSION_FETCH_BUDGET_MS = 2000;
|
|
41
|
+
|
|
42
|
+
type HookInput = { cwd?: string; source?: string };
|
|
43
|
+
|
|
44
|
+
function readStdin(): Promise<string> {
|
|
45
|
+
return new Promise((resolve) => {
|
|
46
|
+
let buf = "";
|
|
47
|
+
process.stdin.setEncoding("utf8");
|
|
48
|
+
process.stdin.on("data", (c) => (buf += c));
|
|
49
|
+
process.stdin.on("end", () => resolve(buf));
|
|
50
|
+
process.stdin.on("error", () => resolve(buf));
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// process.stdout.write() on a Windows pipe is asynchronous — the call can return before the
|
|
55
|
+
// OS-level write completes. Awaiting the write callback guarantees the context JSON is fully
|
|
56
|
+
// flushed before main() resolves, so the process never ends mid-write and truncates it (see
|
|
57
|
+
// pull-memory.ts's identical helper for the full rationale).
|
|
58
|
+
function writeStdout(text: string): Promise<void> {
|
|
59
|
+
return new Promise((resolve) => {
|
|
60
|
+
if (text.length === 0) {
|
|
61
|
+
resolve();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
process.stdout.write(text, () => resolve());
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function main(): Promise<void> {
|
|
69
|
+
let source = "startup";
|
|
70
|
+
let cwd = "";
|
|
71
|
+
try {
|
|
72
|
+
const raw = await readStdin();
|
|
73
|
+
const j: HookInput = JSON.parse(raw);
|
|
74
|
+
if (typeof j.source === "string" && j.source.trim()) source = j.source.trim();
|
|
75
|
+
if (typeof j.cwd === "string") cwd = j.cwd;
|
|
76
|
+
} catch {
|
|
77
|
+
// fall through with defaults; cwd stays empty → resolves to null below
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const resolved = resolveProject(cwd);
|
|
82
|
+
if (!resolved) return; // not a registered project → no output
|
|
83
|
+
|
|
84
|
+
// Sequential under one shared budget (not Promise.all): whatever the first fetch doesn't
|
|
85
|
+
// spend is what the second gets, so the combined worst case is bounded by
|
|
86
|
+
// SESSION_FETCH_BUDGET_MS instead of stacking two independent timeouts.
|
|
87
|
+
const budgetStart = Date.now();
|
|
88
|
+
const defResult = await resolveAgentDefinitionForSession(resolved, {
|
|
89
|
+
timeoutMs: SESSION_FETCH_BUDGET_MS,
|
|
90
|
+
});
|
|
91
|
+
const remainingMs = SESSION_FETCH_BUDGET_MS - (Date.now() - budgetStart);
|
|
92
|
+
const canon = await fetchCanonBlock(resolved, { timeoutMs: remainingMs });
|
|
93
|
+
|
|
94
|
+
let context = buildProtocol(resolved.project, droidPetboxTool, {
|
|
95
|
+
source,
|
|
96
|
+
harness: "droid",
|
|
97
|
+
definition: defResult.definition,
|
|
98
|
+
});
|
|
99
|
+
// Append the curated memory canon when available (best-effort; degrades to nothing).
|
|
100
|
+
if (canon) context += `\n\n${canon}`;
|
|
101
|
+
const out = {
|
|
102
|
+
hookSpecificOutput: {
|
|
103
|
+
hookEventName: "SessionStart",
|
|
104
|
+
additionalContext: context,
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
await writeStdout(JSON.stringify(out));
|
|
108
|
+
} catch {
|
|
109
|
+
// best-effort
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Exit cleanly instead of tearing the process down mid-close: a hard process.exit() while
|
|
114
|
+
// libuv handles from the concurrent HTTP fetches are still closing raced Windows' async
|
|
115
|
+
// handle teardown (`Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), src\win\async.c`)
|
|
116
|
+
// and could truncate the stdout write above (fire-and-forget on a Windows pipe) — the same
|
|
117
|
+
// crash observed in pull-memory.ts (see its exit comment). Setting exitCode and returning lets
|
|
118
|
+
// Node drain the event loop naturally instead — `Connection: close` (canon.ts /
|
|
119
|
+
// agent-def-fetch.ts) covers a completed fetch, and unrefLingeringHandles covers a fetch
|
|
120
|
+
// aborted mid-flight against a stalled server (measured to leave its TLSSocket alive for
|
|
121
|
+
// several more seconds otherwise; see hook-drain.ts) so a slow session start can't turn into
|
|
122
|
+
// a multi-second stall on a handle nothing is still using.
|
|
123
|
+
main().finally(() => {
|
|
124
|
+
process.exitCode = 0;
|
|
125
|
+
unrefLingeringHandles();
|
|
126
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Factory Droid Stop hook (global) — the droid port of push-session.ts.
|
|
2
|
+
//
|
|
3
|
+
// Mirrors the session conversation into PetBox's Session module so the board auto-populates.
|
|
4
|
+
// The project + API key are resolved from cwd via the shared registry; if the cwd is not a
|
|
5
|
+
// registered project this exits immediately (first guard, before any work).
|
|
6
|
+
//
|
|
7
|
+
// Droid's Stop stdin is snake_case (`session_id`, `transcript_path`, `cwd`, `stop_hook_active`)
|
|
8
|
+
// — the same shape Claude Code uses. We parse the droid JSONL (buildDroidMessages: user/
|
|
9
|
+
// assistant TEXT turns only, tool dumps + `<system-reminder>` injections excluded) and push
|
|
10
|
+
// only the INCREMENT via the server-authoritative append cursor (see append.ts): this process
|
|
11
|
+
// is fresh each turn, so it passes null and pushTranscript optimistically resends a small
|
|
12
|
+
// idempotent overlap window; a contiguity gap comes back as a structured 409 with the server's
|
|
13
|
+
// lastOrdinal and the tail is resent from there. Old servers without the append route fall back
|
|
14
|
+
// to the legacy full-snapshot push. Best-effort: every failure is swallowed and we ALWAYS
|
|
15
|
+
// exit 0 — never break the user's session.
|
|
16
|
+
|
|
17
|
+
import { pushTranscript } from "./append.ts";
|
|
18
|
+
import { buildDroidMessages } from "./droid-transcript.ts";
|
|
19
|
+
import { unrefLingeringHandles } from "./hook-drain.ts";
|
|
20
|
+
import { resolveProject } from "./registry.ts";
|
|
21
|
+
import type { Msg } from "./transcript.ts";
|
|
22
|
+
|
|
23
|
+
const FETCH_TIMEOUT_MS = 12000;
|
|
24
|
+
|
|
25
|
+
type HookInput = {
|
|
26
|
+
session_id?: string;
|
|
27
|
+
transcript_path?: string;
|
|
28
|
+
cwd?: string;
|
|
29
|
+
stop_hook_active?: boolean;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function readStdin(): Promise<string> {
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
let buf = "";
|
|
35
|
+
process.stdin.setEncoding("utf8");
|
|
36
|
+
process.stdin.on("data", (c) => (buf += c));
|
|
37
|
+
process.stdin.on("end", () => resolve(buf));
|
|
38
|
+
process.stdin.on("error", () => resolve(buf));
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function main(): Promise<void> {
|
|
43
|
+
try {
|
|
44
|
+
const raw = await readStdin();
|
|
45
|
+
let j: HookInput;
|
|
46
|
+
try {
|
|
47
|
+
j = JSON.parse(raw);
|
|
48
|
+
} catch {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (j.stop_hook_active) return;
|
|
52
|
+
|
|
53
|
+
// FIRST guard: not a registered project → silent no-op (before any file/network work).
|
|
54
|
+
const resolved = resolveProject(j.cwd ?? "");
|
|
55
|
+
if (!resolved) return;
|
|
56
|
+
|
|
57
|
+
const sid = (j.session_id ?? "").trim();
|
|
58
|
+
const tp = (j.transcript_path ?? "").trim();
|
|
59
|
+
if (!sid || !tp) return;
|
|
60
|
+
|
|
61
|
+
let msgs: Msg[];
|
|
62
|
+
try {
|
|
63
|
+
msgs = await buildDroidMessages(tp);
|
|
64
|
+
} catch {
|
|
65
|
+
return; // transcript missing/unreadable
|
|
66
|
+
}
|
|
67
|
+
if (msgs.length === 0) return; // empty body → server returns 400, don't push
|
|
68
|
+
|
|
69
|
+
// Fresh process each turn → no remembered cursor (null): pushTranscript guesses an
|
|
70
|
+
// idempotent overlap window and self-heals off the server's structured gap reject.
|
|
71
|
+
await pushTranscript(
|
|
72
|
+
{
|
|
73
|
+
baseUrl: resolved.baseUrl,
|
|
74
|
+
project: resolved.project,
|
|
75
|
+
sessionId: sid,
|
|
76
|
+
apiKey: resolved.apiKey,
|
|
77
|
+
agent: "droid",
|
|
78
|
+
timeoutMs: FETCH_TIMEOUT_MS,
|
|
79
|
+
},
|
|
80
|
+
msgs,
|
|
81
|
+
null,
|
|
82
|
+
);
|
|
83
|
+
} catch {
|
|
84
|
+
// best-effort: never break the user's session
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Exit cleanly instead of tearing the process down mid-close: a hard process.exit() while
|
|
89
|
+
// libuv handles from the HTTP push above are still closing can race Windows' async handle
|
|
90
|
+
// teardown (`Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), src\win\async.c`) — the
|
|
91
|
+
// same crash observed in pull-memory.ts (see its exit comment). Setting exitCode and returning
|
|
92
|
+
// lets Node drain the event loop naturally instead — `Connection: close` (append.ts) covers a
|
|
93
|
+
// completed push, and unrefLingeringHandles covers a push aborted mid-flight against a stalled
|
|
94
|
+
// server (measured to leave its TLSSocket alive for several more seconds otherwise; see
|
|
95
|
+
// hook-drain.ts) so a slow Stop hook can't turn into a multi-second stall on a handle nothing
|
|
96
|
+
// is still using.
|
|
97
|
+
main().finally(() => {
|
|
98
|
+
process.exitCode = 0;
|
|
99
|
+
unrefLingeringHandles();
|
|
100
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Factory Droid transcript parsing — the droid analogue of transcript.ts, kept as a thin
|
|
2
|
+
// adapter over the SHARED text-extraction/exclusion rules so what a "session turn" is cannot
|
|
3
|
+
// drift between agents (spec: agent-wiring, wiring-single-source).
|
|
4
|
+
//
|
|
5
|
+
// A droid transcript is JSONL, but its record shape differs from Claude Code's:
|
|
6
|
+
// - line 1 is `{type:"session_start", id, title, cwd, ...}` (skipped);
|
|
7
|
+
// - each turn is `{type:"message", message:{role, content}}` where content is either a
|
|
8
|
+
// string or an array of parts `{type:"text"|"thinking"|"tool_use"|"tool_result", ...}`.
|
|
9
|
+
// We keep the user/assistant TEXT turns in order and drop tool_use/tool_result/thinking dumps
|
|
10
|
+
// and harness chrome — reusing extractText (text-parts only) + isExcluded (`<system-reminder>`
|
|
11
|
+
// and friends) from transcript.ts so both agents share one definition. Droid also flags
|
|
12
|
+
// injected context with `visibility:"llm_only"`; we skip those too as a belt-and-suspenders
|
|
13
|
+
// guard alongside the system-reminder prefix check.
|
|
14
|
+
//
|
|
15
|
+
// Plain TS for native node type-stripping: zero deps.
|
|
16
|
+
|
|
17
|
+
import { createReadStream } from "node:fs";
|
|
18
|
+
import { createInterface } from "node:readline";
|
|
19
|
+
import { extractText, isExcluded, type Msg } from "./transcript.ts";
|
|
20
|
+
|
|
21
|
+
// Collect the user/assistant text messages in droid transcript order. No rendering and no
|
|
22
|
+
// cap: the server needs the full, ordered transcript to assign stable per-message ordinals.
|
|
23
|
+
export async function buildDroidMessages(transcriptPath: string): Promise<Msg[]> {
|
|
24
|
+
const rl = createInterface({
|
|
25
|
+
input: createReadStream(transcriptPath, { encoding: "utf8" }),
|
|
26
|
+
crlfDelay: Infinity,
|
|
27
|
+
});
|
|
28
|
+
const msgs: Msg[] = [];
|
|
29
|
+
for await (const line of rl) {
|
|
30
|
+
if (!line || line.trim().length === 0) continue;
|
|
31
|
+
let e: any;
|
|
32
|
+
try {
|
|
33
|
+
e = JSON.parse(line);
|
|
34
|
+
} catch {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (e.type !== "message" || !e.message) continue;
|
|
38
|
+
const role = e.message.role;
|
|
39
|
+
if (role !== "user" && role !== "assistant") continue;
|
|
40
|
+
if (e.message.visibility === "llm_only") continue; // injected context, not a real turn
|
|
41
|
+
const text = extractText(e.message);
|
|
42
|
+
if (text.length === 0) continue; // thinking/tool_use/tool_result-only turn → no text
|
|
43
|
+
if (isExcluded(text)) continue; // <system-reminder> / harness chrome
|
|
44
|
+
msgs.push({ role, content: text });
|
|
45
|
+
}
|
|
46
|
+
return msgs;
|
|
47
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Per-harness capability matrix for the agent-artifact compiler.
|
|
2
|
+
//
|
|
3
|
+
// Spec (definition-truthfulness, per-harness-artifact): a role may only require
|
|
4
|
+
// capabilities the target harness declares. This matrix is kit data (versioned with
|
|
5
|
+
// the npm package) — capabilities change with harness versions, not with the
|
|
6
|
+
// portable agent definition.
|
|
7
|
+
//
|
|
8
|
+
// EVERY cell is a factual claim about a harness, taken from that harness's docs
|
|
9
|
+
// (or a verified live observation). Do not invent; do not copy another harness's row.
|
|
10
|
+
//
|
|
11
|
+
// Plain TS for native node type-stripping: zero deps.
|
|
12
|
+
|
|
13
|
+
export type HarnessId = "claude-code" | "opencode" | "droid";
|
|
14
|
+
|
|
15
|
+
/** Capability ids a role may list in requiredCapabilities. */
|
|
16
|
+
export type Capability =
|
|
17
|
+
| "mcp_main_session"
|
|
18
|
+
| "mcp_subagent"
|
|
19
|
+
| "dynamic_model_at_spawn"
|
|
20
|
+
| "role_files"
|
|
21
|
+
| "builtin_explore_inherits_model"
|
|
22
|
+
| "hooks"
|
|
23
|
+
| "spawn_subagents";
|
|
24
|
+
|
|
25
|
+
export const HARNESS_IDS: readonly HarnessId[] = ["claude-code", "opencode", "droid"] as const;
|
|
26
|
+
|
|
27
|
+
export const CAPABILITIES: readonly Capability[] = [
|
|
28
|
+
"mcp_main_session",
|
|
29
|
+
"mcp_subagent",
|
|
30
|
+
"dynamic_model_at_spawn",
|
|
31
|
+
"role_files",
|
|
32
|
+
"builtin_explore_inherits_model",
|
|
33
|
+
"hooks",
|
|
34
|
+
"spawn_subagents",
|
|
35
|
+
] as const;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Capability matrix — sources cited per cell.
|
|
39
|
+
*
|
|
40
|
+
* claude-code (verified live 2026-07-10 + 2026-07-12 + Anthropic Claude Code agent docs):
|
|
41
|
+
* mcp_main_session — main session has MCP.
|
|
42
|
+
* mcp_subagent — declared (verified live 2026-07-12: a subagent with an unrestricted
|
|
43
|
+
* tool set called an MCP tool, mcp__petbox__whoami, successfully). The earlier
|
|
44
|
+
* "subagents lack MCP" note (2026-07-10) was an artifact of a hand-written
|
|
45
|
+
* ~/.claude/agents/worker.md `tools:` whitelist that hid MCP — not a harness limit.
|
|
46
|
+
* dynamic_model_at_spawn — model passed dynamically at Agent/Task spawn.
|
|
47
|
+
* role_files — project/user agents under .claude/agents/*.md.
|
|
48
|
+
* builtin_explore_inherits_model — built-in Explore inherits parent model by default.
|
|
49
|
+
* hooks — SessionStart/Stop/UserPromptSubmit hook surface.
|
|
50
|
+
* spawn_subagents — main session spawns via Agent/Task tool; subagents do not re-spawn.
|
|
51
|
+
*
|
|
52
|
+
* opencode:
|
|
53
|
+
* mcp_main_session / mcp_subagent — plugin injects system text with no main/subagent
|
|
54
|
+
* branching; MCP tools available the same way in both (kit observation).
|
|
55
|
+
* dynamic_model_at_spawn — NOT declared (opencode PR #18588: model not dynamic at spawn;
|
|
56
|
+
* roles are files only).
|
|
57
|
+
* role_files — .opencode/agent/*.md.
|
|
58
|
+
* builtin_explore_inherits_model — built-in explore inherits parent model.
|
|
59
|
+
* hooks — not a first-class CC-hook surface; leave undeclared.
|
|
60
|
+
* spawn_subagents — can spawn subagents (agent types / Task-equivalent).
|
|
61
|
+
*
|
|
62
|
+
* droid (Factory official docs — do not reintroduce enableHooks-MCP assumptions):
|
|
63
|
+
* role_files — custom droids in .factory/droids/*.md (project) and ~/.factory/droids/
|
|
64
|
+
* (user); project overrides user on name clash.
|
|
65
|
+
* https://docs.factory.ai/cli/configuration/custom-droids
|
|
66
|
+
* spawn_subagents — main session spawns via Task tool (subagent_type = built-in or
|
|
67
|
+
* custom droid name); subagents cannot spawn further (Task unavailable to them).
|
|
68
|
+
* https://docs.factory.ai/cli/configuration/custom-droids
|
|
69
|
+
* dynamic_model_at_spawn — droid frontmatter model: inherit | explicit id; inherit
|
|
70
|
+
* follows parent / complexity routing.
|
|
71
|
+
* https://docs.factory.ai/cli/configuration/custom-droids § Controlling the model
|
|
72
|
+
* mcp_main_session — MCP via .factory/mcp.json (user/folder/project).
|
|
73
|
+
* https://docs.factory.ai/cli/configuration/mcp
|
|
74
|
+
* mcp_subagent — custom droid mcpServers frontmatter scopes which configured MCP
|
|
75
|
+
* servers the subagent receives.
|
|
76
|
+
* https://docs.factory.ai/cli/configuration/custom-droids § Selecting MCP servers
|
|
77
|
+
* hooks — Factory settings hooks (Stop/SessionStart/UserPromptSubmit) used by the kit.
|
|
78
|
+
* builtin_explore_inherits_model — NOT declared: Factory ships built-in `explorer` with
|
|
79
|
+
* model inherit, but we do not equate it to CC/opencode "Explore" without a separate
|
|
80
|
+
* verification pass (leave honestly unspecified).
|
|
81
|
+
* Hierarchy note: org/project/folder/user settings; write project-level .factory/droids/
|
|
82
|
+
* only (additive). https://docs.factory.ai/enterprise/hierarchical-settings-and-org-control
|
|
83
|
+
*/
|
|
84
|
+
const MATRIX: Readonly<Record<HarnessId, readonly Capability[]>> = {
|
|
85
|
+
"claude-code": [
|
|
86
|
+
"mcp_main_session", // live: main has MCP
|
|
87
|
+
"mcp_subagent", // live 2026-07-12: unrestricted subagent called mcp__petbox__whoami
|
|
88
|
+
"dynamic_model_at_spawn", // model at Agent/Task spawn
|
|
89
|
+
"role_files", // .claude/agents/*.md
|
|
90
|
+
"builtin_explore_inherits_model", // built-in Explore inherits parent
|
|
91
|
+
"hooks", // SessionStart/Stop/UserPromptSubmit
|
|
92
|
+
"spawn_subagents", // main spawns; subagents do not re-spawn
|
|
93
|
+
],
|
|
94
|
+
opencode: [
|
|
95
|
+
"mcp_main_session", // same MCP surface main/sub (no branching inject)
|
|
96
|
+
"mcp_subagent", // subagents see MCP like main
|
|
97
|
+
"role_files", // .opencode/agent/*.md
|
|
98
|
+
"builtin_explore_inherits_model", // built-in explore inherits
|
|
99
|
+
"spawn_subagents", // can spawn agent types
|
|
100
|
+
// no dynamic_model_at_spawn — PR #18588
|
|
101
|
+
// no hooks — not CC-hook surface
|
|
102
|
+
],
|
|
103
|
+
droid: [
|
|
104
|
+
"mcp_main_session", // docs: .factory/mcp.json — https://docs.factory.ai/cli/configuration/mcp
|
|
105
|
+
"mcp_subagent", // docs: mcpServers on custom droid — custom-droids § Selecting MCP servers
|
|
106
|
+
"spawn_subagents", // docs: Task tool from main — custom-droids
|
|
107
|
+
"role_files", // docs: .factory/droids/*.md — custom-droids
|
|
108
|
+
"dynamic_model_at_spawn", // docs: model inherit | explicit — custom-droids § model
|
|
109
|
+
"hooks", // kit uses Factory settings hooks
|
|
110
|
+
// no builtin_explore_inherits_model — explorer exists but not equated to CC Explore here
|
|
111
|
+
],
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/** Known capability set for a harness id. Unknown id → empty set (never invent). */
|
|
115
|
+
export function harnessCapabilities(harness: string): ReadonlySet<string> {
|
|
116
|
+
const list = (MATRIX as Record<string, readonly Capability[] | undefined>)[harness];
|
|
117
|
+
return new Set(list ?? []);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function isKnownHarness(id: string): id is HarnessId {
|
|
121
|
+
return (HARNESS_IDS as readonly string[]).includes(id);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function hasCapability(harness: string, capability: string): boolean {
|
|
125
|
+
return harnessCapabilities(harness).has(capability);
|
|
126
|
+
}
|