@zeroroot-ai/zerocool 0.2.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/LICENSE +21 -0
- package/dist/componentize.d.ts +10 -0
- package/dist/componentize.js +149 -0
- package/dist/delegate.d.ts +11 -0
- package/dist/delegate.js +216 -0
- package/dist/findings.d.ts +27 -0
- package/dist/findings.js +109 -0
- package/dist/gibson-tools.d.ts +45 -0
- package/dist/gibson-tools.js +175 -0
- package/dist/http-probe.d.ts +43 -0
- package/dist/http-probe.js +51 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.js +167 -0
- package/dist/knowledge-source.d.ts +21 -0
- package/dist/knowledge-source.js +38 -0
- package/dist/knowledge.d.ts +15 -0
- package/dist/knowledge.js +113 -0
- package/dist/opencode-run.d.ts +70 -0
- package/dist/opencode-run.js +102 -0
- package/dist/serve-agent.d.ts +37 -0
- package/dist/serve-agent.js +144 -0
- package/dist/serve.d.ts +2 -0
- package/dist/serve.js +94 -0
- package/package.json +39 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drive opencode headless — the external driver half of the `kind=agent`
|
|
3
|
+
* dispatched shape (zerocool-plugins#33, owner decision 2026-08-15: Option B).
|
|
4
|
+
*
|
|
5
|
+
* `opencode run --format json` emits NDJSON on stdout, one event per line. The
|
|
6
|
+
* shape below is not inferred from docs; it is what opencode 1.18.13 actually
|
|
7
|
+
* printed:
|
|
8
|
+
*
|
|
9
|
+
* {"type":"step_start","timestamp":…,"sessionID":"ses_…","part":{…}}
|
|
10
|
+
* {"type":"text","timestamp":…,"sessionID":"ses_…","part":{"type":"text","text":"Hi there!…"}}
|
|
11
|
+
* {"type":"step_finish","timestamp":…,"sessionID":"ses_…",
|
|
12
|
+
* "part":{"reason":"stop","tokens":{"total":9787,"input":8740,"output":23,…},"cost":0}}
|
|
13
|
+
*
|
|
14
|
+
* Parsing is split from spawning on purpose: {@link parseOpencodeEvents} is pure
|
|
15
|
+
* and is tested against those exact bytes, so a change in opencode's output
|
|
16
|
+
* fails a unit test instead of a dispatched mission.
|
|
17
|
+
*
|
|
18
|
+
* The parser is deliberately tolerant. A line that is not JSON, or an event type
|
|
19
|
+
* this version does not know, is skipped rather than fatal — opencode is free to
|
|
20
|
+
* add event types, and a mission node must not fail because it did.
|
|
21
|
+
*/
|
|
22
|
+
/** Token accounting for one run, as reported by `step_finish`. */
|
|
23
|
+
export interface OpencodeTokens {
|
|
24
|
+
total: number;
|
|
25
|
+
input: number;
|
|
26
|
+
output: number;
|
|
27
|
+
reasoning: number;
|
|
28
|
+
}
|
|
29
|
+
/** What one headless opencode run produced. */
|
|
30
|
+
export interface OpencodeRunResult {
|
|
31
|
+
/** opencode's session id — pass it back as `sessionId` to continue this session. */
|
|
32
|
+
sessionId: string;
|
|
33
|
+
/** Every `text` part, concatenated in order. The agent's answer. */
|
|
34
|
+
text: string;
|
|
35
|
+
/** `step_finish.reason`, e.g. "stop". Empty when the run produced no finish event. */
|
|
36
|
+
finishReason: string;
|
|
37
|
+
tokens: OpencodeTokens;
|
|
38
|
+
cost: number;
|
|
39
|
+
/** How many NDJSON events parsed. Useful to tell "no output" from "not JSON". */
|
|
40
|
+
events: number;
|
|
41
|
+
}
|
|
42
|
+
/** Parse the NDJSON `opencode run --format json` writes to stdout. */
|
|
43
|
+
export declare function parseOpencodeEvents(stdout: string): OpencodeRunResult;
|
|
44
|
+
export interface OpencodeRunOptions {
|
|
45
|
+
/** The natural-language objective — the mission node's Task.goal. */
|
|
46
|
+
goal: string;
|
|
47
|
+
/** Working directory. opencode edits files here, so it is the run's workspace. */
|
|
48
|
+
dir: string;
|
|
49
|
+
/** `provider/model`, e.g. `gibson/default`. Omitted lets opencode choose. */
|
|
50
|
+
model?: string;
|
|
51
|
+
/** Continue an earlier opencode session instead of starting a new one. */
|
|
52
|
+
sessionId?: string;
|
|
53
|
+
/** Hard deadline. The child is killed when it elapses. */
|
|
54
|
+
timeoutMs?: number;
|
|
55
|
+
/** Extra environment for the child, merged over the parent's. */
|
|
56
|
+
env?: NodeJS.ProcessEnv;
|
|
57
|
+
/** Binary to run. Overridable for tests and for a pinned install. */
|
|
58
|
+
bin?: string;
|
|
59
|
+
}
|
|
60
|
+
/** Build the argv for a headless run. Exported so a test can assert it. */
|
|
61
|
+
export declare function opencodeArgs(opts: OpencodeRunOptions): string[];
|
|
62
|
+
/**
|
|
63
|
+
* Run opencode headless once and return what it produced.
|
|
64
|
+
*
|
|
65
|
+
* Throws on a non-zero exit or a timeout. The caller is
|
|
66
|
+
* {@link ../serve-agent.ts}'s handler, and `startAgentWorker` turns a throw into
|
|
67
|
+
* an in-band `ExecuteResponse.error` — so a crashed run fails its mission node
|
|
68
|
+
* with a reason instead of stalling it until the dispatch times out.
|
|
69
|
+
*/
|
|
70
|
+
export declare function runOpencode(opts: OpencodeRunOptions): Promise<OpencodeRunResult>;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
/** Parse the NDJSON `opencode run --format json` writes to stdout. */
|
|
3
|
+
export function parseOpencodeEvents(stdout) {
|
|
4
|
+
const result = {
|
|
5
|
+
sessionId: "",
|
|
6
|
+
text: "",
|
|
7
|
+
finishReason: "",
|
|
8
|
+
tokens: { total: 0, input: 0, output: 0, reasoning: 0 },
|
|
9
|
+
cost: 0,
|
|
10
|
+
events: 0,
|
|
11
|
+
};
|
|
12
|
+
const chunks = [];
|
|
13
|
+
for (const line of stdout.split("\n")) {
|
|
14
|
+
const trimmed = line.trim();
|
|
15
|
+
if (!trimmed)
|
|
16
|
+
continue;
|
|
17
|
+
let event;
|
|
18
|
+
try {
|
|
19
|
+
event = JSON.parse(trimmed);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
continue; // not an event line (a stray log, a partial write) — not fatal
|
|
23
|
+
}
|
|
24
|
+
result.events += 1;
|
|
25
|
+
if (event.sessionID)
|
|
26
|
+
result.sessionId = event.sessionID;
|
|
27
|
+
if (event.type === "text" && typeof event.part?.text === "string") {
|
|
28
|
+
chunks.push(event.part.text);
|
|
29
|
+
}
|
|
30
|
+
else if (event.type === "step_finish") {
|
|
31
|
+
if (event.part?.reason)
|
|
32
|
+
result.finishReason = event.part.reason;
|
|
33
|
+
if (typeof event.part?.cost === "number")
|
|
34
|
+
result.cost += event.part.cost;
|
|
35
|
+
const t = event.part?.tokens;
|
|
36
|
+
if (t) {
|
|
37
|
+
// Summed, not replaced: a multi-step run emits one step_finish per step
|
|
38
|
+
// and the mission's cost is all of them, not the last one.
|
|
39
|
+
result.tokens.total += t.total ?? 0;
|
|
40
|
+
result.tokens.input += t.input ?? 0;
|
|
41
|
+
result.tokens.output += t.output ?? 0;
|
|
42
|
+
result.tokens.reasoning += t.reasoning ?? 0;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
result.text = chunks.join("");
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
49
|
+
/** Build the argv for a headless run. Exported so a test can assert it. */
|
|
50
|
+
export function opencodeArgs(opts) {
|
|
51
|
+
const args = ["run", "--format", "json", "--dir", opts.dir];
|
|
52
|
+
// Unattended by definition: there is no human to answer a permission prompt,
|
|
53
|
+
// and a run that blocks on one burns its whole timeout and then fails.
|
|
54
|
+
args.push("--auto");
|
|
55
|
+
if (opts.model)
|
|
56
|
+
args.push("--model", opts.model);
|
|
57
|
+
if (opts.sessionId)
|
|
58
|
+
args.push("--session", opts.sessionId);
|
|
59
|
+
// The goal goes last: `message..` is a variadic positional, so anything after
|
|
60
|
+
// it would be swallowed as more message.
|
|
61
|
+
args.push(opts.goal);
|
|
62
|
+
return args;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Run opencode headless once and return what it produced.
|
|
66
|
+
*
|
|
67
|
+
* Throws on a non-zero exit or a timeout. The caller is
|
|
68
|
+
* {@link ../serve-agent.ts}'s handler, and `startAgentWorker` turns a throw into
|
|
69
|
+
* an in-band `ExecuteResponse.error` — so a crashed run fails its mission node
|
|
70
|
+
* with a reason instead of stalling it until the dispatch times out.
|
|
71
|
+
*/
|
|
72
|
+
export async function runOpencode(opts) {
|
|
73
|
+
const bin = opts.bin ?? process.env.ZEROCOOL_OPENCODE_BIN ?? "opencode";
|
|
74
|
+
const args = opencodeArgs(opts);
|
|
75
|
+
return await new Promise((resolve, reject) => {
|
|
76
|
+
const child = spawn(bin, args, {
|
|
77
|
+
cwd: opts.dir,
|
|
78
|
+
env: { ...process.env, ...opts.env },
|
|
79
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
80
|
+
...(opts.timeoutMs && opts.timeoutMs > 0 ? { timeout: opts.timeoutMs, killSignal: "SIGTERM" } : {}),
|
|
81
|
+
});
|
|
82
|
+
let stdout = "";
|
|
83
|
+
let stderr = "";
|
|
84
|
+
child.stdout.on("data", (d) => (stdout += d.toString()));
|
|
85
|
+
child.stderr.on("data", (d) => (stderr += d.toString()));
|
|
86
|
+
child.on("error", (e) => reject(new Error(`could not run ${bin}: ${e.message}`)));
|
|
87
|
+
child.on("close", (code, signal) => {
|
|
88
|
+
if (signal) {
|
|
89
|
+
// Killed by the timeout. Say so with the deadline, because "terminated"
|
|
90
|
+
// alone sends whoever reads the mission looking for a crash.
|
|
91
|
+
reject(new Error(`opencode timed out after ${opts.timeoutMs}ms (killed with ${signal})`));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (code !== 0) {
|
|
95
|
+
const tail = stderr.trim().split("\n").slice(-5).join("; ");
|
|
96
|
+
reject(new Error(`opencode exited ${code}${tail ? `: ${tail}` : ""}`));
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
resolve(parseOpencodeEvents(stdout));
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* zerocool-agent — run zerocool headless as a daemon-driven `kind=agent`.
|
|
4
|
+
*
|
|
5
|
+
* The sibling of `zerocool-serve`, and the other half of dispatched mode. Where
|
|
6
|
+
* `zerocool-serve` registers a TOOL — one named capability with declared
|
|
7
|
+
* parameters — this registers an AGENT: a goal-driven executor. A mission node
|
|
8
|
+
* hands over a Task, and this process pursues it by driving opencode headless
|
|
9
|
+
* for as long as the dispatch allows.
|
|
10
|
+
*
|
|
11
|
+
* This is zerocool-plugins#33, Option B, per the owner decision of 2026-08-15.
|
|
12
|
+
* It does not reopen ADR-0005: the driver is a bin in this package, not a fork
|
|
13
|
+
* of opencode, which is exactly the carve-out ADR-0005 wrote. See
|
|
14
|
+
* `docs/adr/0006-kind-agent-dispatched-shape.md`.
|
|
15
|
+
*
|
|
16
|
+
* Usage (after `gibson agent enroll --kind agent --name zerocool`):
|
|
17
|
+
*
|
|
18
|
+
* GIBSON_PLATFORM_URL=https://api.example:30443 \
|
|
19
|
+
* GIBSON_BOOTSTRAP_TOKEN=<one-time token> \
|
|
20
|
+
* ZEROCOOL_AGENT_NAME=zerocool \
|
|
21
|
+
* ZEROCOOL_WORKSPACE=/srv/work \
|
|
22
|
+
* zerocool-agent
|
|
23
|
+
*
|
|
24
|
+
* The token is needed for the first check-in only; afterwards the persisted host
|
|
25
|
+
* key re-registers this host without human involvement (ADR-0045).
|
|
26
|
+
*/
|
|
27
|
+
import { type AgentInvocation, type AgentOutcome } from "@zeroroot-ai/sdk";
|
|
28
|
+
/**
|
|
29
|
+
* Turn one dispatched Task into one headless opencode run.
|
|
30
|
+
*
|
|
31
|
+
* Exported for tests: the loop around it is `startAgentWorker`'s, and the value
|
|
32
|
+
* of this function is entirely in what it maps onto and off the wire.
|
|
33
|
+
*/
|
|
34
|
+
export declare function agentHandler(item: AgentInvocation, opts: {
|
|
35
|
+
workspace: string;
|
|
36
|
+
model?: string;
|
|
37
|
+
}): Promise<AgentOutcome>;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* zerocool-agent — run zerocool headless as a daemon-driven `kind=agent`.
|
|
4
|
+
*
|
|
5
|
+
* The sibling of `zerocool-serve`, and the other half of dispatched mode. Where
|
|
6
|
+
* `zerocool-serve` registers a TOOL — one named capability with declared
|
|
7
|
+
* parameters — this registers an AGENT: a goal-driven executor. A mission node
|
|
8
|
+
* hands over a Task, and this process pursues it by driving opencode headless
|
|
9
|
+
* for as long as the dispatch allows.
|
|
10
|
+
*
|
|
11
|
+
* This is zerocool-plugins#33, Option B, per the owner decision of 2026-08-15.
|
|
12
|
+
* It does not reopen ADR-0005: the driver is a bin in this package, not a fork
|
|
13
|
+
* of opencode, which is exactly the carve-out ADR-0005 wrote. See
|
|
14
|
+
* `docs/adr/0006-kind-agent-dispatched-shape.md`.
|
|
15
|
+
*
|
|
16
|
+
* Usage (after `gibson agent enroll --kind agent --name zerocool`):
|
|
17
|
+
*
|
|
18
|
+
* GIBSON_PLATFORM_URL=https://api.example:30443 \
|
|
19
|
+
* GIBSON_BOOTSTRAP_TOKEN=<one-time token> \
|
|
20
|
+
* ZEROCOOL_AGENT_NAME=zerocool \
|
|
21
|
+
* ZEROCOOL_WORKSPACE=/srv/work \
|
|
22
|
+
* zerocool-agent
|
|
23
|
+
*
|
|
24
|
+
* The token is needed for the first check-in only; afterwards the persisted host
|
|
25
|
+
* key re-registers this host without human involvement (ADR-0045).
|
|
26
|
+
*/
|
|
27
|
+
import { connectGibson, registerInstance, startAgentWorker, startHeartbeat, } from "@zeroroot-ai/sdk";
|
|
28
|
+
import { existsSync } from "node:fs";
|
|
29
|
+
import { homedir } from "node:os";
|
|
30
|
+
import { join } from "node:path";
|
|
31
|
+
import { runOpencode } from "./opencode-run.js";
|
|
32
|
+
/** Version reported to the daemon on registration. */
|
|
33
|
+
const VERSION = "0.1.0";
|
|
34
|
+
/**
|
|
35
|
+
* Turn one dispatched Task into one headless opencode run.
|
|
36
|
+
*
|
|
37
|
+
* Exported for tests: the loop around it is `startAgentWorker`'s, and the value
|
|
38
|
+
* of this function is entirely in what it maps onto and off the wire.
|
|
39
|
+
*/
|
|
40
|
+
export async function agentHandler(item, opts) {
|
|
41
|
+
if (!item.goal.trim()) {
|
|
42
|
+
// A dispatch with no goal is a defect upstream, not something to guess at.
|
|
43
|
+
// Failing loudly beats running opencode against an empty prompt and
|
|
44
|
+
// reporting whatever it says back as a mission result.
|
|
45
|
+
throw new Error(`work ${item.workId} carried no task goal`);
|
|
46
|
+
}
|
|
47
|
+
const run = await runOpencode({
|
|
48
|
+
goal: item.goal,
|
|
49
|
+
dir: opts.workspace,
|
|
50
|
+
model: opts.model,
|
|
51
|
+
// Continuity across dispatches: the daemon's agent_run_id is stable for a
|
|
52
|
+
// retried node, but opencode's own session id is what carries the
|
|
53
|
+
// conversation. It is returned in metadata below so a follow-up dispatch can
|
|
54
|
+
// pass it back — see the ADR on why this is not yet automatic.
|
|
55
|
+
sessionId: item.context.opencode_session_id,
|
|
56
|
+
timeoutMs: item.timeoutMs,
|
|
57
|
+
env: {
|
|
58
|
+
// The task-scoped callback seam. gibson mints these per dispatch
|
|
59
|
+
// (`internal/engine/harness/implementation.go:1205-1210`) so a dispatched
|
|
60
|
+
// run reaches the harness as the TASK rather than as this component.
|
|
61
|
+
// Passed through to the child now; the plugin consuming them in
|
|
62
|
+
// preference to the host-key grant is tracked in the ADR as the next
|
|
63
|
+
// slice — today the child still authenticates as the component, which is
|
|
64
|
+
// broader authority than the dispatch intends.
|
|
65
|
+
...(item.callbackEndpoint ? { GIBSON_CALLBACK_ENDPOINT: item.callbackEndpoint } : {}),
|
|
66
|
+
...(item.callbackToken ? { GIBSON_CALLBACK_TOKEN: item.callbackToken } : {}),
|
|
67
|
+
// Provenance, so anything the child emits can be correlated to this run.
|
|
68
|
+
...(item.missionRunId ? { GIBSON_MISSION_RUN_ID: item.missionRunId } : {}),
|
|
69
|
+
...(item.agentRunId ? { GIBSON_AGENT_RUN_ID: item.agentRunId } : {}),
|
|
70
|
+
...(item.traceId ? { GIBSON_TRACE_ID: item.traceId } : {}),
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
return {
|
|
74
|
+
output: run.text,
|
|
75
|
+
metadata: {
|
|
76
|
+
opencode_session_id: run.sessionId,
|
|
77
|
+
finish_reason: run.finishReason,
|
|
78
|
+
tokens_total: String(run.tokens.total),
|
|
79
|
+
tokens_output: String(run.tokens.output),
|
|
80
|
+
cost: String(run.cost),
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
async function main() {
|
|
85
|
+
const platformURL = process.env.GIBSON_PLATFORM_URL;
|
|
86
|
+
if (!platformURL) {
|
|
87
|
+
console.error("[zerocool-agent] GIBSON_PLATFORM_URL is required");
|
|
88
|
+
process.exit(2);
|
|
89
|
+
}
|
|
90
|
+
const agentName = process.env.ZEROCOOL_AGENT_NAME ?? "zerocool";
|
|
91
|
+
const workspace = process.env.ZEROCOOL_WORKSPACE ?? process.cwd();
|
|
92
|
+
const model = process.env.ZEROCOOL_MODEL;
|
|
93
|
+
const hostKeyPath = process.env.GIBSON_HOST_KEY_PATH ?? join(homedir(), ".zerocool", "host.key");
|
|
94
|
+
const checkedIn = existsSync(hostKeyPath);
|
|
95
|
+
const session = await connectGibson({
|
|
96
|
+
platformURL,
|
|
97
|
+
daemonURL: process.env.GIBSON_DAEMON_URL,
|
|
98
|
+
bootstrapToken: checkedIn ? undefined : process.env.GIBSON_BOOTSTRAP_TOKEN,
|
|
99
|
+
hostKeyPath,
|
|
100
|
+
agentName,
|
|
101
|
+
agentMode: "dispatched",
|
|
102
|
+
agent: { name: agentName, version: VERSION, capabilities: ["code"] },
|
|
103
|
+
});
|
|
104
|
+
// Register the AGENT identity the harness dispatches `agent_execute` to, and
|
|
105
|
+
// hold the ONE shared InstanceRef the heartbeat and work loop both use —
|
|
106
|
+
// renewal is single-flight inside the ref, so an expired instance is
|
|
107
|
+
// re-registered once and both loops adopt the new id (sdk-ts#6).
|
|
108
|
+
const ref = await registerInstance(session.clients.component, "agent", {
|
|
109
|
+
name: agentName,
|
|
110
|
+
version: VERSION,
|
|
111
|
+
capabilities: ["code"],
|
|
112
|
+
metadata: { served_by: "zerocool", workspace },
|
|
113
|
+
});
|
|
114
|
+
const stopHeartbeat = startHeartbeat(session.clients.component, ref, (e) => console.error(`[zerocool-agent] heartbeat: ${e.message}`));
|
|
115
|
+
console.error(`[zerocool-agent] registered agent "${agentName}" (instance=${ref.current()}) ` +
|
|
116
|
+
`workspace=${workspace}; polling for work`);
|
|
117
|
+
const stop = startAgentWorker(session.clients.component, ref, {
|
|
118
|
+
onWork: (item) => console.error(`[zerocool-agent] claimed work ${item.workId} run=${item.agentRunId || "-"} ` +
|
|
119
|
+
`timeout=${item.timeoutMs}ms goal=${JSON.stringify(item.goal.slice(0, 120))}`),
|
|
120
|
+
onError: (e) => console.error(`[zerocool-agent] ${e.message}`),
|
|
121
|
+
handler: async (item) => {
|
|
122
|
+
const outcome = await agentHandler(item, { workspace, model });
|
|
123
|
+
console.error(`[zerocool-agent] finished ${item.workId} reason=${outcome.metadata?.finish_reason ?? "-"} ` +
|
|
124
|
+
`tokens=${outcome.metadata?.tokens_total ?? "-"}`);
|
|
125
|
+
return outcome;
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
const shutdown = () => {
|
|
129
|
+
console.error("[zerocool-agent] stopping");
|
|
130
|
+
stop();
|
|
131
|
+
stopHeartbeat();
|
|
132
|
+
session.stop();
|
|
133
|
+
process.exit(0);
|
|
134
|
+
};
|
|
135
|
+
process.on("SIGINT", shutdown);
|
|
136
|
+
process.on("SIGTERM", shutdown);
|
|
137
|
+
}
|
|
138
|
+
// Only run when executed as the bin, so the handler above stays importable.
|
|
139
|
+
if (process.argv[1]?.endsWith("serve-agent.js")) {
|
|
140
|
+
main().catch((e) => {
|
|
141
|
+
console.error(`[zerocool-agent] fatal: ${e.message}`);
|
|
142
|
+
process.exit(1);
|
|
143
|
+
});
|
|
144
|
+
}
|
package/dist/serve.d.ts
ADDED
package/dist/serve.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* zerocool-serve — run zerocool headless as a dispatched fleet component.
|
|
4
|
+
*
|
|
5
|
+
* The inverse of the opencode plugin: instead of a human driving the agent, the
|
|
6
|
+
* daemon drives it. The process checks in with the Capability Grant handshake,
|
|
7
|
+
* registers as a TOOL, and then blocks on PollWork until a mission node sends
|
|
8
|
+
* it something to do.
|
|
9
|
+
*
|
|
10
|
+
* WHY kind=tool. Every kind receives dispatched work now — agent nodes included
|
|
11
|
+
* (gibson#1197 / ADR-0011) — so this is a choice, not a limitation. A tool is
|
|
12
|
+
* the honest description of what this process offers: one named capability with
|
|
13
|
+
* declared parameters, invoked by whoever needs it. Registering as an agent
|
|
14
|
+
* would promise a goal-driven executor with its own reasoning loop, which is
|
|
15
|
+
* what the opencode plugin does interactively, not what this server does.
|
|
16
|
+
*
|
|
17
|
+
* Usage (after `gibson agent enroll --kind tool --name <name>`):
|
|
18
|
+
*
|
|
19
|
+
* GIBSON_PLATFORM_URL=https://api.example:30443 \
|
|
20
|
+
* GIBSON_BOOTSTRAP_TOKEN=<one-time token> \
|
|
21
|
+
* ZEROCOOL_TOOL_NAME=zerocool-http \
|
|
22
|
+
* zerocool-serve # the package's bin; node dist/serve.js works too
|
|
23
|
+
*
|
|
24
|
+
* The token is needed for the first check-in only; afterwards the persisted
|
|
25
|
+
* host key re-registers this host without human involvement (ADR-0045).
|
|
26
|
+
*/
|
|
27
|
+
import { connectGibson, registerInstance, startHeartbeat, startWorker, } from "@zeroroot-ai/sdk";
|
|
28
|
+
import { existsSync } from "node:fs";
|
|
29
|
+
import { homedir } from "node:os";
|
|
30
|
+
import { join } from "node:path";
|
|
31
|
+
import { httpProbeHandler } from "./http-probe.js";
|
|
32
|
+
async function main() {
|
|
33
|
+
const platformURL = process.env.GIBSON_PLATFORM_URL;
|
|
34
|
+
if (!platformURL) {
|
|
35
|
+
console.error("[zerocool-serve] GIBSON_PLATFORM_URL is required");
|
|
36
|
+
process.exit(2);
|
|
37
|
+
}
|
|
38
|
+
const toolName = process.env.ZEROCOOL_TOOL_NAME ?? "zerocool-http";
|
|
39
|
+
const hostKeyPath = process.env.GIBSON_HOST_KEY_PATH ?? join(homedir(), ".zerocool", "host.key");
|
|
40
|
+
const checkedIn = existsSync(hostKeyPath);
|
|
41
|
+
const session = await connectGibson({
|
|
42
|
+
platformURL,
|
|
43
|
+
daemonURL: process.env.GIBSON_DAEMON_URL,
|
|
44
|
+
// One-time token on first check-in only; the host key carries every later start.
|
|
45
|
+
bootstrapToken: checkedIn ? undefined : process.env.GIBSON_BOOTSTRAP_TOKEN,
|
|
46
|
+
hostKeyPath,
|
|
47
|
+
agentName: toolName,
|
|
48
|
+
agentMode: "dispatched",
|
|
49
|
+
agent: { name: toolName, version: "0.1.0", capabilities: ["http_probe"] },
|
|
50
|
+
});
|
|
51
|
+
// Register under the kind that actually receives work. connectGibson has
|
|
52
|
+
// already registered an agent identity for the session; this adds the tool
|
|
53
|
+
// identity the harness dispatches to. registerInstance returns the ONE
|
|
54
|
+
// shared InstanceRef the heartbeat and the work loop both use — renewal is
|
|
55
|
+
// single-flight inside the ref, so an expired instance is re-registered
|
|
56
|
+
// once and both loops adopt the new id (sdk-ts#6).
|
|
57
|
+
const ref = await registerInstance(session.clients.component, "tool", {
|
|
58
|
+
name: toolName,
|
|
59
|
+
version: "0.1.0",
|
|
60
|
+
capabilities: ["http_probe"],
|
|
61
|
+
metadata: { served_by: "zerocool" },
|
|
62
|
+
});
|
|
63
|
+
// Heartbeat the TOOL identity, not just the session's agent identity. The
|
|
64
|
+
// daemon expires an instance that stops heartbeating and PollWork then answers
|
|
65
|
+
// NotFound forever — a process that looks healthy while being invisible to the
|
|
66
|
+
// fleet.
|
|
67
|
+
const stopHeartbeat = startHeartbeat(session.clients.component, ref, (e) => console.error(`[zerocool-serve] heartbeat: ${e.message}`));
|
|
68
|
+
console.error(`[zerocool-serve] registered tool "${toolName}" (instance=${ref.current()}); polling for work`);
|
|
69
|
+
const stop = startWorker(session.clients.component, ref, {
|
|
70
|
+
onWork: (item) => console.error(`[zerocool-serve] claimed work ${item.workId} (${item.workType}) input=${JSON.stringify(item.input)}`),
|
|
71
|
+
onError: (e) => console.error(`[zerocool-serve] ${e.message}`),
|
|
72
|
+
// Handlers get the session's clients: the callback-harness seam for
|
|
73
|
+
// LLM/tools/findings/knowledge during a dispatched run.
|
|
74
|
+
handler: async (item) => {
|
|
75
|
+
const result = await httpProbeHandler(item, session.clients);
|
|
76
|
+
console.error(`[zerocool-serve] ${result.url} -> ${result.status} ${result.statusText}, ` +
|
|
77
|
+
`server=${result.server ?? "-"}, ${result.bytes} bytes in ${result.elapsedMs}ms`);
|
|
78
|
+
return result;
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
const shutdown = () => {
|
|
82
|
+
console.error("[zerocool-serve] stopping");
|
|
83
|
+
stop();
|
|
84
|
+
stopHeartbeat();
|
|
85
|
+
session.stop();
|
|
86
|
+
process.exit(0);
|
|
87
|
+
};
|
|
88
|
+
process.on("SIGINT", shutdown);
|
|
89
|
+
process.on("SIGTERM", shutdown);
|
|
90
|
+
}
|
|
91
|
+
main().catch((e) => {
|
|
92
|
+
console.error(`[zerocool-serve] fatal: ${e.message}`);
|
|
93
|
+
process.exit(1);
|
|
94
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zeroroot-ai/zerocool",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "opencode plugin: Gibson platform integration (zerocool). Zero-config LLM, tools, findings, knowledge, delegate/missions.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "dist/index.js",
|
|
8
|
+
"types": "dist/index.d.ts",
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@zeroroot-ai/sdk": "git+https://github.com/zeroroot-ai/sdk-ts.git#v0.4.0"
|
|
14
|
+
},
|
|
15
|
+
"peerDependencies": {
|
|
16
|
+
"@opencode-ai/plugin": "*"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@opencode-ai/plugin": "^1.18.11"
|
|
20
|
+
},
|
|
21
|
+
"bin": {
|
|
22
|
+
"zerocool-serve": "dist/serve.js",
|
|
23
|
+
"zerocool-agent": "dist/serve-agent.js"
|
|
24
|
+
},
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"import": "./dist/index.js"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "tsc -p tsconfig.json",
|
|
36
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
37
|
+
"test": "tsc -p tsconfig.test.json && node --test \"dist-test/**/*.test.js\""
|
|
38
|
+
}
|
|
39
|
+
}
|