@khorsheed/dsh-local-agent-dsh-headless 0.1.0-rc.6
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.en.md +97 -0
- package/README.i18n.yaml +6 -0
- package/README.md +97 -0
- package/cordis.patch.yml +81 -0
- package/lib/index.js +507 -0
- package/lib/invariant.js +36 -0
- package/lib/startup.js +74 -0
- package/lib/types/agent-loader.d.ts +85 -0
- package/lib/types/agent-loader.js +173 -0
- package/lib/types/index.d.ts +69 -0
- package/lib/types/index.js +99 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/invariant.js +41 -0
- package/lib/types/serve.d.ts +39 -0
- package/lib/types/serve.js +249 -0
- package/lib/types/startup.d.ts +47 -0
- package/lib/types/startup.js +96 -0
- package/lib/types/wire.d.ts +116 -0
- package/lib/types/wire.js +24 -0
- package/lib/wire.js +27 -0
- package/package.json +65 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
import { LIVE_SERVER_NAME, LIVE_WIRE_ERROR_INTERNAL, LIVE_WIRE_ERROR_METHOD_NOT_FOUND } from "./wire.js";
|
|
2
|
+
import z from "@deepseek-ai/schemastery";
|
|
3
|
+
import { createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { installModelSelection } from "@deepseek-ai/dsh-agent";
|
|
6
|
+
import { SessionId } from "@deepseek-ai/dsh-session";
|
|
7
|
+
import { StringDecoder } from "node:string_decoder";
|
|
8
|
+
//#region lib/types/agent-loader.js
|
|
9
|
+
/**
|
|
10
|
+
* Agent loading and turn aggregation shared by the one-shot runner and the
|
|
11
|
+
* serve loop: both drive a direct Agent over the core registry with the same
|
|
12
|
+
* model selection, the same scoped installModelSelection setup, the same
|
|
13
|
+
* preset join, and the same caller-supplied session identity, differing only
|
|
14
|
+
* in process lifetime.
|
|
15
|
+
* @module @khorsheed/dsh-local-agent-dsh-headless/agent-loader
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Aggregate the last assistant text and turn outcome in one owned interval.
|
|
19
|
+
* @param events - the agent session's full event log.
|
|
20
|
+
* @param firstSeq - the session seq at which this driver's interval opened.
|
|
21
|
+
*/
|
|
22
|
+
function summarizeTurn(events, firstSeq) {
|
|
23
|
+
let started = false;
|
|
24
|
+
let text = "";
|
|
25
|
+
let reason;
|
|
26
|
+
for (const event of events) {
|
|
27
|
+
if (event.seq < firstSeq) continue;
|
|
28
|
+
if (event.type === "turn/start") {
|
|
29
|
+
started = true;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (!started) continue;
|
|
33
|
+
if (event.type === "assistant/message") {
|
|
34
|
+
const joined = event.data.message.content.filter((block) => block.type === "text").map((block) => block.text).join("");
|
|
35
|
+
if (joined !== "") text = joined;
|
|
36
|
+
}
|
|
37
|
+
if (event.type === "turn/end") reason = event.data.reason;
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
text,
|
|
41
|
+
reason
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Apply a `--model` request onto the instance's default selection.
|
|
46
|
+
*
|
|
47
|
+
* The request is spelled `provider/model` — the same shape
|
|
48
|
+
* `effectiveSettings.model` reports the configured model in — and splits at
|
|
49
|
+
* the FIRST slash, so a provider-qualified id survives a model id that
|
|
50
|
+
* contains none. A request with no slash names the MODEL only and keeps the
|
|
51
|
+
* default selection's provider, which is what a caller switching between two
|
|
52
|
+
* models of the same route means.
|
|
53
|
+
*
|
|
54
|
+
* Everything else on the selection is carried through untouched (the
|
|
55
|
+
* reasoning effort in particular): `--model` changes the model, not the rest
|
|
56
|
+
* of the instance's configuration. An absent or blank request returns the
|
|
57
|
+
* default selection unchanged — byte for byte the behavior before the flag
|
|
58
|
+
* existed.
|
|
59
|
+
* @param selection - the instance's default model selection.
|
|
60
|
+
* @param request - the `--model` value, when the launch carried one.
|
|
61
|
+
* @returns the selection this launch runs.
|
|
62
|
+
*/
|
|
63
|
+
function applyModelRequest(selection, request) {
|
|
64
|
+
const wanted = request?.trim();
|
|
65
|
+
if (wanted === void 0 || wanted === "") return selection;
|
|
66
|
+
const slash = wanted.indexOf("/");
|
|
67
|
+
if (slash <= 0 || slash === wanted.length - 1) return {
|
|
68
|
+
...selection,
|
|
69
|
+
model: wanted
|
|
70
|
+
};
|
|
71
|
+
return {
|
|
72
|
+
...selection,
|
|
73
|
+
provider: wanted.slice(0, slash),
|
|
74
|
+
model: wanted.slice(slash + 1)
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Join this agent to the sub-profile's preset, when the sub-profile composes
|
|
79
|
+
* a roster.
|
|
80
|
+
*
|
|
81
|
+
* Without a roster this is a no-op and the agent reads the model-facing rows
|
|
82
|
+
* off the global layer — byte for byte the behavior of every sub-dsh before
|
|
83
|
+
* the roster layer existed, and the right answer for a rosterless
|
|
84
|
+
* composition (`@deepseek-ai/dsh-agent-presets` README, "Composing a child
|
|
85
|
+
* agent"). With one, the preset decides the tools, prompt sections and
|
|
86
|
+
* skills of every session this process hosts, which is what makes the
|
|
87
|
+
* scope's capability face a per-condition factor instead of an instance-wide
|
|
88
|
+
* constant.
|
|
89
|
+
*
|
|
90
|
+
* A roster that REFUSES (unknown preset id, broken composition) is not
|
|
91
|
+
* degraded past: mounting rejects, the agent creation rolls back, and the
|
|
92
|
+
* launch fails naming the preset. A sub-dsh that silently ran the global
|
|
93
|
+
* layer after being told to run preset X would attribute the round to a
|
|
94
|
+
* capability face it never had.
|
|
95
|
+
* @param ctx - plugin context (where the roster service is resolved).
|
|
96
|
+
* @param agentCtx - the agent's scope context, which joins the standing mount.
|
|
97
|
+
* @returns the preset id joined, or undefined when the composition has no roster.
|
|
98
|
+
*/
|
|
99
|
+
async function joinSubDshPreset(ctx, agentCtx) {
|
|
100
|
+
const roster = ctx.get("agentPresets");
|
|
101
|
+
if (roster?.mount === void 0) return void 0;
|
|
102
|
+
return (await roster.mount(agentCtx)).id;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Create or resume the direct Agent for one caller-named session. The caller
|
|
106
|
+
* resolved the core services first (both call sites bail out on a disposed
|
|
107
|
+
* tree). The caller-supplied id is authoritative for both branches: the
|
|
108
|
+
* parent provider generated one uuid and passes the same value on every round
|
|
109
|
+
* of the same delegation, so resume finds exactly the sub-dsh session the
|
|
110
|
+
* fresh round created.
|
|
111
|
+
* @param ctx - plugin context carrying the Agent registry and default model.
|
|
112
|
+
* @param identity - fresh (`sessionId`) or continuation (`resumeSessionId`).
|
|
113
|
+
* @returns the owned agent handle (its disposer unwinds the agent's world).
|
|
114
|
+
*/
|
|
115
|
+
async function loadSubDshAgent(ctx, identity) {
|
|
116
|
+
const agents = ctx.get("agents");
|
|
117
|
+
if (agents === void 0) throw new Error("local-agent-dsh-headless: the agents service is not mounted");
|
|
118
|
+
const selection = await resolveSubDshSelection(ctx, identity.model, identity.effort);
|
|
119
|
+
const agentOptions = {
|
|
120
|
+
provider: selection.provider,
|
|
121
|
+
model: selection.model
|
|
122
|
+
};
|
|
123
|
+
const setup = async (agentCtx) => {
|
|
124
|
+
installModelSelection(agentCtx, {
|
|
125
|
+
current: selection,
|
|
126
|
+
assembled: void 0
|
|
127
|
+
});
|
|
128
|
+
await joinSubDshPreset(ctx, agentCtx);
|
|
129
|
+
};
|
|
130
|
+
if (identity.resumeSessionId !== void 0) return agents.resume({
|
|
131
|
+
resumeSessionId: SessionId(identity.resumeSessionId),
|
|
132
|
+
agentOptions,
|
|
133
|
+
setup
|
|
134
|
+
});
|
|
135
|
+
return agents.create({
|
|
136
|
+
sessionId: SessionId(identity.sessionId ?? `session-${randomUUID()}`),
|
|
137
|
+
meta: { cwd: process.cwd() },
|
|
138
|
+
agentOptions,
|
|
139
|
+
setup
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
/** Validate the actual sub-instance's configuration without creating a session or generating. */
|
|
143
|
+
async function resolveSubDshSelection(ctx, model, effort, validateModel = false) {
|
|
144
|
+
const defaults = ctx.get("agentDefaultModel");
|
|
145
|
+
if (defaults === void 0) throw new Error("local-agent-dsh-headless: agentDefaultModel is not mounted");
|
|
146
|
+
const selection = applyModelRequest(defaults.currentSelection(), model);
|
|
147
|
+
if (effort === void 0 && !validateModel) return selection;
|
|
148
|
+
let timer;
|
|
149
|
+
try {
|
|
150
|
+
const info = await Promise.race([ctx.get("llm")?.resolveModelInfo(selection.provider, selection.model, AbortSignal.timeout(5e3)), new Promise((_resolve, reject) => {
|
|
151
|
+
timer = setTimeout(() => reject(/* @__PURE__ */ new Error("Sub-DSH model validation timed out")), 5e3);
|
|
152
|
+
timer.unref();
|
|
153
|
+
})]);
|
|
154
|
+
if (info === void 0) throw new Error("The sub-DSH adapter cannot resolve the requested model");
|
|
155
|
+
if (effort === void 0) return selection;
|
|
156
|
+
const native = info.reasoning?.efforts.find((option) => String(option.id) === effort);
|
|
157
|
+
if (native === void 0) throw new Error("The sub-DSH model does not advertise the requested reasoning effort");
|
|
158
|
+
return {
|
|
159
|
+
...selection,
|
|
160
|
+
reasoningEffort: native.id
|
|
161
|
+
};
|
|
162
|
+
} finally {
|
|
163
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
//#endregion
|
|
167
|
+
//#region lib/types/serve.js
|
|
168
|
+
/**
|
|
169
|
+
* Serve mode: the resident sub-dsh loop behind the family-internal live-driver
|
|
170
|
+
* wire (see `./wire.ts`). Where the one-shot runner drives a single task and
|
|
171
|
+
* exits, serve mode keeps the tree alive on stdin: each `turn/start` request
|
|
172
|
+
* feeds one followup into the caller-named session's Agent, every session
|
|
173
|
+
* event streams back as a `session/event` notification while it happens, and
|
|
174
|
+
* `session/idle` closes the turn after the log flush. `turn/interrupt`
|
|
175
|
+
* delivers the runtime-level graceful cancel (`Agent.cancel`) that the
|
|
176
|
+
* process boundary cannot express — the process survives and the session
|
|
177
|
+
* stays continuable, which is the live driver's reason to exist.
|
|
178
|
+
* @module @khorsheed/dsh-local-agent-dsh-headless/serve
|
|
179
|
+
*/
|
|
180
|
+
/** Flatten an unknown thrown value for a wire error or stderr line. */
|
|
181
|
+
function messageOf(error) {
|
|
182
|
+
return error instanceof Error ? error.message : String(error);
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Run the resident loop until stdin closes or a `shutdown` request arrives.
|
|
186
|
+
* @param ctx - plugin context carrying the loader settle, core services, and
|
|
187
|
+
* the session/event feed.
|
|
188
|
+
* @param io - process-facing effects.
|
|
189
|
+
*/
|
|
190
|
+
async function runServe(ctx, io, model, effort) {
|
|
191
|
+
await ctx.get("loader")?.await();
|
|
192
|
+
const agents = ctx.get("agents");
|
|
193
|
+
const sessions = ctx.get("sessions");
|
|
194
|
+
if (agents === void 0 || sessions === void 0 || ctx.get("agentDefaultModel") === void 0) {
|
|
195
|
+
io.stderr.write("dsh: serve mode requires the agents, sessions, and agentDefaultModel services\n");
|
|
196
|
+
io.exit(1);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
/** Loaded agents by caller session id; one member per process in practice. */
|
|
200
|
+
const managed = /* @__PURE__ */ new Map();
|
|
201
|
+
/** Per-session turn serialization: one in-flight turn per session, FIFO. */
|
|
202
|
+
const queues = /* @__PURE__ */ new Map();
|
|
203
|
+
/**
|
|
204
|
+
* The in-flight round's parent-supplied turn number per session, set while
|
|
205
|
+
* a turn body runs and echoed on its notifications — the parent drops a
|
|
206
|
+
* cancelled round's late unwind instead of mis-settling the next round.
|
|
207
|
+
*/
|
|
208
|
+
const activeTurns = /* @__PURE__ */ new Map();
|
|
209
|
+
let shuttingDown = false;
|
|
210
|
+
let buffer = "";
|
|
211
|
+
const decoder = new StringDecoder("utf8");
|
|
212
|
+
const send = (message) => {
|
|
213
|
+
io.stdout.write(JSON.stringify(message) + "\n");
|
|
214
|
+
};
|
|
215
|
+
const respond = (id, result) => {
|
|
216
|
+
send({
|
|
217
|
+
jsonrpc: "2.0",
|
|
218
|
+
id,
|
|
219
|
+
result
|
|
220
|
+
});
|
|
221
|
+
};
|
|
222
|
+
const respondError = (id, message, code = LIVE_WIRE_ERROR_INTERNAL) => {
|
|
223
|
+
send({
|
|
224
|
+
jsonrpc: "2.0",
|
|
225
|
+
id,
|
|
226
|
+
error: {
|
|
227
|
+
code,
|
|
228
|
+
message
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
};
|
|
232
|
+
const notify = (method, params) => {
|
|
233
|
+
send({
|
|
234
|
+
jsonrpc: "2.0",
|
|
235
|
+
method,
|
|
236
|
+
params
|
|
237
|
+
});
|
|
238
|
+
};
|
|
239
|
+
ctx.on("session/event", (session, event) => {
|
|
240
|
+
if (shuttingDown || !managed.has(String(session.id))) return;
|
|
241
|
+
const id = String(session.id);
|
|
242
|
+
notify("session/event", {
|
|
243
|
+
sessionId: id,
|
|
244
|
+
turn: activeTurns.get(id) ?? null,
|
|
245
|
+
event
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
ctx.on("agent/assistant-stream", ({ agent, frame }) => {
|
|
249
|
+
const sessionId = String(agent.session.id);
|
|
250
|
+
const turn = activeTurns.get(sessionId);
|
|
251
|
+
if (shuttingDown || !managed.has(sessionId) || turn === void 0) return;
|
|
252
|
+
notify("session/assistant-stream", {
|
|
253
|
+
sessionId,
|
|
254
|
+
turn,
|
|
255
|
+
frame
|
|
256
|
+
});
|
|
257
|
+
}, { global: true });
|
|
258
|
+
const debug = process.env["DSH_SERVE_DEBUG"] === "1" ? (message) => {
|
|
259
|
+
io.stderr.write(`dsh-serve: ${message}\n`);
|
|
260
|
+
} : () => {};
|
|
261
|
+
const shutdown = async () => {
|
|
262
|
+
if (shuttingDown) return;
|
|
263
|
+
shuttingDown = true;
|
|
264
|
+
debug("shutdown requested");
|
|
265
|
+
for (const handle of managed.values()) await handle.dispose().catch(() => {});
|
|
266
|
+
debug("managed agents disposed; requesting exit");
|
|
267
|
+
io.exit(0);
|
|
268
|
+
const stdin = io.stdin;
|
|
269
|
+
stdin.removeAllListeners?.("data");
|
|
270
|
+
stdin.removeAllListeners?.("end");
|
|
271
|
+
stdin.unref?.();
|
|
272
|
+
setTimeout(() => {
|
|
273
|
+
debug("exit fallback fired");
|
|
274
|
+
process.exit(0);
|
|
275
|
+
}, 2e3).unref();
|
|
276
|
+
};
|
|
277
|
+
/** Accept one turn: followup now, outcome later as `session/idle`. */
|
|
278
|
+
const startTurn = (sessionId, text, turn, agent) => {
|
|
279
|
+
const run = (queues.get(sessionId) ?? Promise.resolve()).then(async () => {
|
|
280
|
+
activeTurns.set(sessionId, turn);
|
|
281
|
+
let reason;
|
|
282
|
+
try {
|
|
283
|
+
const firstSeq = agent.session.seq;
|
|
284
|
+
agent.followup(createUserMessage({
|
|
285
|
+
content: [{
|
|
286
|
+
type: "text",
|
|
287
|
+
text
|
|
288
|
+
}],
|
|
289
|
+
source: { kind: "user" }
|
|
290
|
+
}));
|
|
291
|
+
await agent.whenIdle();
|
|
292
|
+
await sessions.flush(agent.session);
|
|
293
|
+
reason = summarizeTurn(agent.session.snapshotEvents(), firstSeq).reason ?? null;
|
|
294
|
+
} catch (error) {
|
|
295
|
+
reason = {
|
|
296
|
+
kind: "error",
|
|
297
|
+
error: {
|
|
298
|
+
message: messageOf(error),
|
|
299
|
+
code: "UNKNOWN"
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
const params = {
|
|
304
|
+
sessionId,
|
|
305
|
+
turn,
|
|
306
|
+
reason
|
|
307
|
+
};
|
|
308
|
+
try {
|
|
309
|
+
notify("session/idle", params);
|
|
310
|
+
} finally {
|
|
311
|
+
activeTurns.delete(sessionId);
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
queues.set(sessionId, run);
|
|
315
|
+
};
|
|
316
|
+
const handleRequest = async (id, method, params) => {
|
|
317
|
+
switch (method) {
|
|
318
|
+
case "initialize":
|
|
319
|
+
respond(id, {
|
|
320
|
+
serverInfo: { name: LIVE_SERVER_NAME },
|
|
321
|
+
protocolVersion: 1
|
|
322
|
+
});
|
|
323
|
+
return;
|
|
324
|
+
case "session/prepare":
|
|
325
|
+
try {
|
|
326
|
+
await resolveSubDshSelection(ctx, model, effort, true);
|
|
327
|
+
respond(id, { prepared: true });
|
|
328
|
+
} catch (error) {
|
|
329
|
+
respondError(id, messageOf(error));
|
|
330
|
+
}
|
|
331
|
+
return;
|
|
332
|
+
case "turn/start": {
|
|
333
|
+
const sessionId = params["sessionId"];
|
|
334
|
+
const text = params["text"];
|
|
335
|
+
const turn = params["turn"];
|
|
336
|
+
const resume = params["resume"] === true;
|
|
337
|
+
if (typeof sessionId !== "string" || sessionId === "" || typeof text !== "string" || typeof turn !== "number" || !Number.isSafeInteger(turn) || turn < 1) {
|
|
338
|
+
respondError(id, "turn/start requires a sessionId, a text, and a positive integer turn");
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
let handle = managed.get(sessionId);
|
|
342
|
+
if (handle === void 0) {
|
|
343
|
+
try {
|
|
344
|
+
handle = await loadSubDshAgent(ctx, {
|
|
345
|
+
...resume ? { resumeSessionId: sessionId } : { sessionId },
|
|
346
|
+
...model === void 0 ? {} : { model },
|
|
347
|
+
...effort === void 0 ? {} : { effort }
|
|
348
|
+
});
|
|
349
|
+
} catch (error) {
|
|
350
|
+
respondError(id, messageOf(error));
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
managed.set(sessionId, handle);
|
|
354
|
+
}
|
|
355
|
+
startTurn(sessionId, text, turn, handle.agent);
|
|
356
|
+
respond(id, { accepted: true });
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
case "turn/interrupt": {
|
|
360
|
+
const sessionId = params["sessionId"];
|
|
361
|
+
if (typeof sessionId !== "string") {
|
|
362
|
+
respondError(id, "turn/interrupt requires a sessionId");
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
const handle = managed.get(sessionId);
|
|
366
|
+
handle?.agent.cancel({ kind: "parent" });
|
|
367
|
+
respond(id, { interrupted: handle !== void 0 });
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
case "shutdown":
|
|
371
|
+
respond(id, {});
|
|
372
|
+
shutdown();
|
|
373
|
+
return;
|
|
374
|
+
default: respondError(id, `unknown method: ${method}`, LIVE_WIRE_ERROR_METHOD_NOT_FOUND);
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
io.stdin.on("data", (chunk) => {
|
|
378
|
+
if (shuttingDown) return;
|
|
379
|
+
const text = typeof chunk === "string" ? chunk : decoder.write(chunk);
|
|
380
|
+
buffer += text;
|
|
381
|
+
let index = buffer.indexOf("\n");
|
|
382
|
+
while (index >= 0) {
|
|
383
|
+
const line = buffer.slice(0, index);
|
|
384
|
+
buffer = buffer.slice(index + 1);
|
|
385
|
+
index = buffer.indexOf("\n");
|
|
386
|
+
if (line.trim() === "") continue;
|
|
387
|
+
let message;
|
|
388
|
+
try {
|
|
389
|
+
message = JSON.parse(line);
|
|
390
|
+
} catch {
|
|
391
|
+
io.stderr.write("dsh: serve ignored a malformed wire line\n");
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
if ((typeof message.id === "number" || typeof message.id === "string") && typeof message.method === "string") {
|
|
395
|
+
const id = Number(message.id);
|
|
396
|
+
handleRequest(id, message.method, message.params ?? {}).catch((error) => {
|
|
397
|
+
respondError(id, messageOf(error));
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
io.stdin.on("end", () => {
|
|
403
|
+
shutdown();
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
//#endregion
|
|
407
|
+
//#region lib/types/index.js
|
|
408
|
+
/**
|
|
409
|
+
* @khorsheed/dsh-local-agent-dsh-headless — sub-dsh direct Agent driver with
|
|
410
|
+
* two modes. One-shot (default): create one Agent through the core registry
|
|
411
|
+
* on a caller-supplied session id (or resume one), drive the task to
|
|
412
|
+
* quiescence, flush its Session, print the final assistant text, and exit.
|
|
413
|
+
* Serve (`--serve`): stay resident and drive turns over the family-internal
|
|
414
|
+
* live-driver wire (see `./serve.ts` / `./wire.ts`). The session id comes
|
|
415
|
+
* from the invocation (`--session-id` / `--resume`) or the wire, never from
|
|
416
|
+
* stdout — the parent provider generates one uuid and both sides use it, so
|
|
417
|
+
* stdout carries no parseable session marker.
|
|
418
|
+
*
|
|
419
|
+
* @module @khorsheed/dsh-local-agent-dsh-headless
|
|
420
|
+
*/
|
|
421
|
+
/** Stable Cordis plugin name. */
|
|
422
|
+
const name = "local-agent-dsh-headless-runner";
|
|
423
|
+
/** Core services required before the one-shot turn can start. */
|
|
424
|
+
const inject = [
|
|
425
|
+
"agentDefaultModel",
|
|
426
|
+
"agents",
|
|
427
|
+
"sessions"
|
|
428
|
+
];
|
|
429
|
+
const Config = z.object({
|
|
430
|
+
task: z.string(),
|
|
431
|
+
sessionId: z.string(),
|
|
432
|
+
resumeSessionId: z.string(),
|
|
433
|
+
serve: z.boolean().default(false),
|
|
434
|
+
model: z.string(),
|
|
435
|
+
effort: z.string()
|
|
436
|
+
});
|
|
437
|
+
/** The process streams the runner writes to; tests substitute captures. */
|
|
438
|
+
const internals = {
|
|
439
|
+
stdin: process.stdin,
|
|
440
|
+
stdout: process.stdout,
|
|
441
|
+
stderr: process.stderr
|
|
442
|
+
};
|
|
443
|
+
/** Report an unexpected direct-driver failure and request a failing exit. */
|
|
444
|
+
function fail(io, error) {
|
|
445
|
+
io.stderr.write(`dsh: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
446
|
+
io.exit(1);
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Run one task through a fresh or resumed Agent and request process exit.
|
|
450
|
+
* @param ctx - plugin context carrying the Agent, default model, Session, and launcher IO services.
|
|
451
|
+
* @param config - validated one-shot config (task plus the caller session id).
|
|
452
|
+
* @param io - process-facing effects.
|
|
453
|
+
*/
|
|
454
|
+
async function run(ctx, config, io) {
|
|
455
|
+
await ctx.get("loader")?.await();
|
|
456
|
+
const sessions = ctx.get("sessions");
|
|
457
|
+
if (ctx.get("agents") === void 0 || ctx.get("agentDefaultModel") === void 0 || sessions === void 0) return;
|
|
458
|
+
const agent = (await loadSubDshAgent(ctx, {
|
|
459
|
+
...config.sessionId === void 0 ? {} : { sessionId: config.sessionId },
|
|
460
|
+
...config.resumeSessionId === void 0 ? {} : { resumeSessionId: config.resumeSessionId },
|
|
461
|
+
...config.model === void 0 ? {} : { model: config.model },
|
|
462
|
+
...config.effort === void 0 ? {} : { effort: config.effort }
|
|
463
|
+
})).agent;
|
|
464
|
+
await agent.whenIdle();
|
|
465
|
+
const firstSeq = agent.session.seq;
|
|
466
|
+
agent.followup(createUserMessage({
|
|
467
|
+
content: [{
|
|
468
|
+
type: "text",
|
|
469
|
+
text: config.task ?? ""
|
|
470
|
+
}],
|
|
471
|
+
source: { kind: "user" }
|
|
472
|
+
}));
|
|
473
|
+
await agent.whenIdle();
|
|
474
|
+
await sessions.flush(agent.session);
|
|
475
|
+
const outcome = summarizeTurn(agent.session.snapshotEvents(), firstSeq);
|
|
476
|
+
io.stdout.write(outcome.text + "\n");
|
|
477
|
+
if (outcome.reason?.kind === "error") io.stderr.write(`dsh: ${outcome.reason.error.code}: ${outcome.reason.error.message}\n`);
|
|
478
|
+
io.exit(outcome.reason?.kind === "completed" ? 0 : 1);
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Mount the direct driver: one-shot by default, the resident serve loop when
|
|
482
|
+
* the invocation passed `--serve`.
|
|
483
|
+
* @param ctx - plugin context carrying core services and the launcher-provided exit request.
|
|
484
|
+
* @param config - validated task/serve config.
|
|
485
|
+
*/
|
|
486
|
+
function apply(ctx, config) {
|
|
487
|
+
const exit = ctx.get("appExit");
|
|
488
|
+
if (exit === void 0) throw new Error("local-agent-dsh-headless-runner: the launcher must provide ctx.appExit before the tree mounts");
|
|
489
|
+
const io = {
|
|
490
|
+
stdin: internals.stdin,
|
|
491
|
+
stdout: internals.stdout,
|
|
492
|
+
stderr: internals.stderr,
|
|
493
|
+
exit
|
|
494
|
+
};
|
|
495
|
+
if (config.serve === true) {
|
|
496
|
+
runServe(ctx, io, config.model, config.effort).catch((error) => {
|
|
497
|
+
fail(io, error);
|
|
498
|
+
});
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
if (config.task === void 0 || config.task.trim() === "") throw new Error("local-agent-dsh-headless-runner: a task is required unless serve mode is on");
|
|
502
|
+
run(ctx, config, io).catch((error) => {
|
|
503
|
+
fail(io, error);
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
//#endregion
|
|
507
|
+
export { Config, apply, inject, internals, name };
|
package/lib/invariant.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
//#region lib/types/invariant.js
|
|
2
|
+
/**
|
|
3
|
+
* Package-owned invariant companion for `@khorsheed/dsh-local-agent-dsh-headless`.
|
|
4
|
+
* @module @khorsheed/dsh-local-agent-dsh-headless/invariant
|
|
5
|
+
*/
|
|
6
|
+
const PACKAGE_NAME = "@khorsheed/dsh-local-agent-dsh-headless";
|
|
7
|
+
/** Cordis companion plugin name. */
|
|
8
|
+
const name = "local-agent-dsh-headless-invariant";
|
|
9
|
+
/** Service required before the companion can register. */
|
|
10
|
+
const inject = ["invariants"];
|
|
11
|
+
/**
|
|
12
|
+
* Composition guard: this bundle is sub-profile-only. Its patch rows
|
|
13
|
+
* (persona override, `hmr` off, `tools` mode, the `ptc-runtime` insert, the
|
|
14
|
+
* member-bridge MCP row) collide with or leak into any Host/web composition —
|
|
15
|
+
* 2026-08-23 P0: reconciled into the prod web profile as a direct dependency
|
|
16
|
+
* (reconcilePlugins auto-mounts every `dsh.bundle`-declaring direct dep), the
|
|
17
|
+
* `code-runtime` insert (today's `ptc-runtime` row) hit the web-app row's duplicate entry id and the
|
|
18
|
+
* whole instance failed to boot. The loader's duplicate-id failure is loud
|
|
19
|
+
* but cryptic, and only fires when the ids collide; this invariant is the
|
|
20
|
+
* CLEAR failure for the non-colliding case (e.g. an upstream row rename would
|
|
21
|
+
* otherwise leak the persona override into real user sessions). The web
|
|
22
|
+
* layer's `webStartup` service is the marker: in-box bundles mount before
|
|
23
|
+
* dependency-managed ones, so it is already provided when this companion
|
|
24
|
+
* applies.
|
|
25
|
+
*/
|
|
26
|
+
const install = (ctx, fail) => {
|
|
27
|
+
if (ctx.get("webStartup") !== void 0) fail("mounted into a web composition — this bundle is sub-profile-only. Remove it from the profile’s direct dependencies: the parent local-agent-dsh bundle installs it transitively, and the sub-profile symlink resolves from that closure");
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Register this package's invariant companion.
|
|
31
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
32
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
33
|
+
*/
|
|
34
|
+
const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
35
|
+
//#endregion
|
|
36
|
+
export { apply, inject, name };
|
package/lib/startup.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { parseCmdline } from "@deepseek-ai/dsh-cmdline";
|
|
3
|
+
//#region lib/types/startup.js
|
|
4
|
+
/**
|
|
5
|
+
* The sub-dsh one-shot app's command-line provider: it parses the task
|
|
6
|
+
* positional, the caller-supplied `--session-id` (fresh delegation) and
|
|
7
|
+
* `--resume` (continuation) flags, and `--help`, then publishes
|
|
8
|
+
* {@link LOCAL_AGENT_DSH_HEADLESS_STARTUP_SERVICE}. The runner is an ordinary
|
|
9
|
+
* consumer whose lazy config waits for that service. The session id is
|
|
10
|
+
* caller-supplied — the parent provider generates one uuid and passes the same
|
|
11
|
+
* value to `agents.create`/`agents.resume` on the sub-dsh side, so stdout
|
|
12
|
+
* never carries a parseable session marker.
|
|
13
|
+
* @module @khorsheed/dsh-local-agent-dsh-headless/startup
|
|
14
|
+
*/
|
|
15
|
+
/** Stable Cordis plugin name. */
|
|
16
|
+
const name = "local-agent-dsh-headless-startup";
|
|
17
|
+
/** Services required before the invocation can be resolved. */
|
|
18
|
+
const inject = ["cmdlineArgs"];
|
|
19
|
+
/** Service provided by this plugin and injected by the one-shot runner. */
|
|
20
|
+
const LOCAL_AGENT_DSH_HEADLESS_STARTUP_SERVICE = "localAgentDshHeadlessStartup";
|
|
21
|
+
/**
|
|
22
|
+
* This app's command: the session flags, the task positional, and help text.
|
|
23
|
+
* @returns a fresh program, so one process can parse more than once (tests).
|
|
24
|
+
*/
|
|
25
|
+
function headlessCommand() {
|
|
26
|
+
return new Command().name("dsh --profile headless-local-agent-dsh").description("Answer one task in a caller-named session, print the final assistant message, and exit.").helpOption("-h, --help", "show this help").option("--session-id <id>", "create a fresh session with exactly this id").option("--resume <id>", "continue the existing session with this id").option("--serve", "stay resident and drive turns over the stdio wire (no task)").option("--model <provider/model>", "run this model instead of the instance default (a bare id keeps the provider)").option("--effort <value>", "native reasoning effort for this launch").argument("[task...]", "the task text; multiple words are joined by spaces").addHelpText("after", `
|
|
27
|
+
Examples:
|
|
28
|
+
dsh --profile headless-local-agent-dsh --session-id 6ba7... "run the tests" create one session and answer
|
|
29
|
+
dsh --profile headless-local-agent-dsh --resume 6ba7... "run the rest" continue the same session
|
|
30
|
+
dsh --profile headless-local-agent-dsh --serve resident live-driver mode (stdio wire)
|
|
31
|
+
dsh --profile headless-local-agent-dsh --model deepseek-official/deepseek-v4-pro --session-id 6ba7... "run the tests"
|
|
32
|
+
`);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Parse and provide the one-shot invocation as an ordinary Cordis service.
|
|
36
|
+
* The command's action publishes the invocation; a missing or whitespace-only
|
|
37
|
+
* task, or both session flags together, is a usage error, so on rejection (and
|
|
38
|
+
* on `--help`) nothing is provided.
|
|
39
|
+
* @param ctx - plugin context carrying the command line.
|
|
40
|
+
*/
|
|
41
|
+
function apply(ctx) {
|
|
42
|
+
const program = headlessCommand();
|
|
43
|
+
program.action(() => {
|
|
44
|
+
const task = program.args.join(" ");
|
|
45
|
+
const options = program.opts();
|
|
46
|
+
const effort = options.effort?.trim();
|
|
47
|
+
if (effort === "") program.error("error: --effort needs a native reasoning value");
|
|
48
|
+
const model = options.model?.trim();
|
|
49
|
+
if (model !== void 0 && model === "") program.error("error: --model needs a model identifier, for example deepseek-official/deepseek-v4-pro");
|
|
50
|
+
if (options.serve === true) {
|
|
51
|
+
if (options.sessionId !== void 0 || options.resume !== void 0) program.error("error: --serve drives sessions over the wire; --session-id/--resume do not apply");
|
|
52
|
+
if (task.trim() !== "") program.error("error: --serve takes no task; turns arrive over the wire");
|
|
53
|
+
ctx.provide(LOCAL_AGENT_DSH_HEADLESS_STARTUP_SERVICE, {
|
|
54
|
+
task: "",
|
|
55
|
+
serve: true,
|
|
56
|
+
...model === void 0 ? {} : { model },
|
|
57
|
+
...effort === void 0 ? {} : { effort }
|
|
58
|
+
});
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (task.trim() === "") program.error("error: a task is required, for example: dsh --profile headless-local-agent-dsh --session-id <id> \"run the tests\"");
|
|
62
|
+
if (options.sessionId !== void 0 && options.resume !== void 0) program.error("error: --session-id and --resume are mutually exclusive");
|
|
63
|
+
ctx.provide(LOCAL_AGENT_DSH_HEADLESS_STARTUP_SERVICE, {
|
|
64
|
+
task,
|
|
65
|
+
...options.sessionId !== void 0 ? { sessionId: options.sessionId } : {},
|
|
66
|
+
...options.resume !== void 0 ? { resumeSessionId: options.resume } : {},
|
|
67
|
+
...model === void 0 ? {} : { model },
|
|
68
|
+
...effort === void 0 ? {} : { effort }
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
parseCmdline(ctx, program);
|
|
72
|
+
}
|
|
73
|
+
//#endregion
|
|
74
|
+
export { LOCAL_AGENT_DSH_HEADLESS_STARTUP_SERVICE, apply, inject, name };
|