hilos-agent 0.6.0 → 0.9.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 +29 -3
- package/bin/hilos-agent.mjs +16 -1
- package/package.json +1 -1
- package/src/acp-session.mjs +538 -0
- package/src/agent-events.mjs +232 -2
- package/src/cli.mjs +33 -4
- package/src/config.mjs +8 -0
- package/src/daemon.mjs +23 -0
- package/src/handler.mjs +558 -95
- package/src/opencode-permissions.mjs +654 -0
- package/src/opencode-session.mjs +770 -0
- package/src/progress-emitter.mjs +103 -9
- package/src/resume.mjs +85 -12
- package/src/run.mjs +10 -0
- package/src/thread-pr.mjs +305 -0
package/src/agent-events.mjs
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// alive" epic: coding CLIs each narrate their work in a different, unstable
|
|
3
3
|
// wire format (Claude Code emits `--output-format stream-json` NDJSON, Codex
|
|
4
4
|
// emits `--json` item events, Cursor emits its own `--output-format
|
|
5
|
-
// stream-json` NDJSON — 0573
|
|
5
|
+
// stream-json` NDJSON — 0573, opencode emits `--format json` part events —
|
|
6
|
+
// 0608). This module turns any of them into ONE small,
|
|
6
7
|
// typed `AgentEvent` stream the UI can render as a live "what the agent is
|
|
7
8
|
// doing right now" card.
|
|
8
9
|
//
|
|
@@ -218,6 +219,15 @@ function parseCodexLine(line) {
|
|
|
218
219
|
* `shellToolCall`, each with an `args` object), and a terminal `result` with
|
|
219
220
|
* `is_error` + the full text in `result`. `thinking` deltas, the `user` echo,
|
|
220
221
|
* and `tool_call` completions are deliberately not steps.
|
|
222
|
+
*
|
|
223
|
+
* LOCKSTEP (0574 phase 2 / 0749): `parseCursorCloudEvent` in
|
|
224
|
+
* lib/hosted-progress.ts is this function's CLOUD twin — same field knowledge,
|
|
225
|
+
* same restraint (`started` only, unknown tools skipped, never throws) — for
|
|
226
|
+
* Cursor's Cloud Agents SSE stream, whose event NAMES differ from this CLI
|
|
227
|
+
* stream-json (`tool_call` with a flat `name` + `args`, not `<kind>ToolCall`
|
|
228
|
+
* envelopes). Change one, look at the other. The cloud side's mappings are
|
|
229
|
+
* WIRE (unconfirmed) until a real cloud transcript is captured; the shapes
|
|
230
|
+
* below were captured live and are not.
|
|
221
231
|
*/
|
|
222
232
|
function parseCursorLine(line) {
|
|
223
233
|
const obj = tryParse(line);
|
|
@@ -272,12 +282,119 @@ function cursorToolEvent(toolCall) {
|
|
|
272
282
|
return null;
|
|
273
283
|
}
|
|
274
284
|
|
|
285
|
+
/** One opencode `tool_use` part → an AgentEvent, or null. Tool names are
|
|
286
|
+
* lowercase (`read`/`write`/`edit`/`patch`/`bash`); the arguments live under
|
|
287
|
+
* `state.input` (`filePath` for file tools, `command` for bash). Anything else
|
|
288
|
+
* (glob/grep/webfetch/task) carries less "alive" signal and is skipped, mirroring
|
|
289
|
+
* toolKind()'s v1 restraint — an unknown tool never crashes the parser. */
|
|
290
|
+
function opencodeToolEvent(part) {
|
|
291
|
+
if (!part || typeof part !== "object") return null;
|
|
292
|
+
const tool = typeof part.tool === "string" ? part.tool.toLowerCase() : "";
|
|
293
|
+
const state = part.state && typeof part.state === "object" ? part.state : {};
|
|
294
|
+
const input = state.input && typeof state.input === "object" ? state.input : {};
|
|
295
|
+
if (tool === "bash") {
|
|
296
|
+
const raw = input.command;
|
|
297
|
+
const cmd = typeof raw === "string" ? raw.replace(/\s+/g, " ").trim() : "";
|
|
298
|
+
return { t: "run", cmd: sanitizeText(cmd) };
|
|
299
|
+
}
|
|
300
|
+
if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
|
|
301
|
+
const raw = input.filePath ?? input.file_path ?? input.path;
|
|
302
|
+
return { t: "edit", path: typeof raw === "string" ? sanitizeText(raw) : "" };
|
|
303
|
+
}
|
|
304
|
+
if (tool === "read") {
|
|
305
|
+
const raw = input.filePath ?? input.file_path ?? input.path;
|
|
306
|
+
return { t: "read", path: typeof raw === "string" ? sanitizeText(raw) : "" };
|
|
307
|
+
}
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* A stateful line parser for opencode `run --format json` NDJSON (0608).
|
|
313
|
+
*
|
|
314
|
+
* Shapes captured LIVE against opencode 1.18.5: EVERY event carries a top-level
|
|
315
|
+
* `sessionID` (`ses_…`) — including the very first `step_start` and the terminal
|
|
316
|
+
* `error` — which is exactly the id `--session` resumes. `text` events carry a
|
|
317
|
+
* WHOLE text part (`part.text`), not a delta, so a part is emitted once and a
|
|
318
|
+
* repeat of the same `part.id` with identical text is dropped (a growing part
|
|
319
|
+
* re-emits, so the room still sees the latest wording). `tool_use` events carry
|
|
320
|
+
* the tool + its input; they're deduped by `callID` so a state update can't
|
|
321
|
+
* double a step. `step_finish` closes an assistant turn (`part.reason`): `stop`
|
|
322
|
+
* ends the run, so it becomes the `result` carrying the last text as the summary
|
|
323
|
+
* (opencode has no separate result event, and the folder-run report reads that
|
|
324
|
+
* summary). `error` (`{ error: { name, data: { message } } }`) → a failed result.
|
|
325
|
+
*
|
|
326
|
+
* Stateful (last text, seen part/call ids) — hence a factory, not a bare fn.
|
|
327
|
+
*/
|
|
328
|
+
function makeOpencodeLineParser() {
|
|
329
|
+
const seenText = new Map(); // part.id → last emitted text
|
|
330
|
+
const seenCalls = new Set(); // tool callID
|
|
331
|
+
let sessionSeen = false;
|
|
332
|
+
let lastText = "";
|
|
333
|
+
return function parseOpencodeLine(line) {
|
|
334
|
+
const obj = tryParse(line);
|
|
335
|
+
if (!obj) return [];
|
|
336
|
+
const out = [];
|
|
337
|
+
// The session id rides on every event; emit it once, off whichever lands first.
|
|
338
|
+
if (!sessionSeen && typeof obj.sessionID === "string" && obj.sessionID) {
|
|
339
|
+
sessionSeen = true;
|
|
340
|
+
out.push({ t: "session", sessionId: sanitizeText(obj.sessionID) });
|
|
341
|
+
}
|
|
342
|
+
const part = obj.part && typeof obj.part === "object" ? obj.part : {};
|
|
343
|
+
switch (obj.type) {
|
|
344
|
+
case "text": {
|
|
345
|
+
const text = typeof part.text === "string" ? sanitizeText(part.text.replace(/\s+/g, " ").trim()) : "";
|
|
346
|
+
if (!text) break;
|
|
347
|
+
const id = typeof part.id === "string" ? part.id : "";
|
|
348
|
+
if (id && seenText.get(id) === text) break; // same part, same text → not news
|
|
349
|
+
if (id) {
|
|
350
|
+
seenText.set(id, text);
|
|
351
|
+
if (seenText.size > 200) seenText.delete(seenText.keys().next().value); // bound
|
|
352
|
+
}
|
|
353
|
+
lastText = text;
|
|
354
|
+
out.push({ t: "note", text });
|
|
355
|
+
break;
|
|
356
|
+
}
|
|
357
|
+
case "tool_use": {
|
|
358
|
+
const callId = typeof part.callID === "string" ? part.callID : "";
|
|
359
|
+
if (callId && seenCalls.has(callId)) break; // status updates repeat the call
|
|
360
|
+
if (callId) {
|
|
361
|
+
seenCalls.add(callId);
|
|
362
|
+
if (seenCalls.size > 500) seenCalls.delete(seenCalls.values().next().value); // bound
|
|
363
|
+
}
|
|
364
|
+
const ev = opencodeToolEvent(part);
|
|
365
|
+
if (ev) out.push(ev);
|
|
366
|
+
break;
|
|
367
|
+
}
|
|
368
|
+
case "step_finish": {
|
|
369
|
+
// Only the turn that stops ends the RUN; `tool-calls` means another
|
|
370
|
+
// assistant turn follows.
|
|
371
|
+
if (part.reason === "stop") {
|
|
372
|
+
out.push(lastText ? { t: "result", ok: true, summary: lastText } : { t: "result", ok: true });
|
|
373
|
+
}
|
|
374
|
+
break;
|
|
375
|
+
}
|
|
376
|
+
case "error": {
|
|
377
|
+
const err = obj.error && typeof obj.error === "object" ? obj.error : {};
|
|
378
|
+
const data = err.data && typeof err.data === "object" ? err.data : {};
|
|
379
|
+
const raw = typeof data.message === "string" ? data.message : typeof err.name === "string" ? err.name : "";
|
|
380
|
+
const summary = sanitizeText(raw);
|
|
381
|
+
out.push(summary ? { t: "result", ok: false, summary } : { t: "result", ok: false });
|
|
382
|
+
break;
|
|
383
|
+
}
|
|
384
|
+
default:
|
|
385
|
+
break;
|
|
386
|
+
}
|
|
387
|
+
return out;
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
275
391
|
/** claude / claude_code / claude-code all mean the Claude parser. */
|
|
276
392
|
function normalizeVendor(vendor) {
|
|
277
393
|
const v = String(vendor || "").toLowerCase();
|
|
278
394
|
if (v === "claude" || v === "claude_code" || v === "claude-code") return "claude";
|
|
279
395
|
if (v === "codex") return "codex";
|
|
280
396
|
if (v === "cursor") return "cursor"; // structured stream-json since 0573
|
|
397
|
+
if (v === "opencode") return "opencode"; // structured `--format json` since 0608
|
|
281
398
|
return "text"; // ANY unknown vendor → lastLine text-tail fallback
|
|
282
399
|
}
|
|
283
400
|
|
|
@@ -345,7 +462,7 @@ function makeTextTailParser() {
|
|
|
345
462
|
|
|
346
463
|
/**
|
|
347
464
|
* Build a stateful stream parser for `vendor`.
|
|
348
|
-
* @param {'claude'|'claude_code'|'codex'|'cursor'|string} vendor
|
|
465
|
+
* @param {'claude'|'claude_code'|'codex'|'cursor'|'opencode'|string} vendor
|
|
349
466
|
* @returns {{ push: (chunk: string) => AgentEvent[], flush: () => AgentEvent[] }}
|
|
350
467
|
*/
|
|
351
468
|
export function makeStreamParser(vendor) {
|
|
@@ -353,6 +470,9 @@ export function makeStreamParser(vendor) {
|
|
|
353
470
|
if (v === "claude") return makeLineBufferedParser(parseClaudeLine);
|
|
354
471
|
if (v === "codex") return makeLineBufferedParser(parseCodexLine);
|
|
355
472
|
if (v === "cursor") return makeLineBufferedParser(parseCursorLine);
|
|
473
|
+
// opencode's line parser carries per-stream state (dedupe + last text), so
|
|
474
|
+
// each parser instance gets its own.
|
|
475
|
+
if (v === "opencode") return makeLineBufferedParser(makeOpencodeLineParser());
|
|
356
476
|
return makeTextTailParser();
|
|
357
477
|
}
|
|
358
478
|
|
|
@@ -437,3 +557,113 @@ export function summarizeSteps(events, limit = 8) {
|
|
|
437
557
|
for (const ev of events || []) ring.push(ev);
|
|
438
558
|
return ring.labels();
|
|
439
559
|
}
|
|
560
|
+
|
|
561
|
+
// ── Activity fold (0537/0750) ────────────────────────────────────────────────
|
|
562
|
+
//
|
|
563
|
+
// The step ring's richer sibling: structured feed rows the run card renders as
|
|
564
|
+
// sentences that MUTATE IN PLACE. LOCKSTEP with the server's fold in
|
|
565
|
+
// `lib/hosted-progress.ts` (the hosted lanes) — same tense rule (the sentence
|
|
566
|
+
// is formatted at read time so "Editing lib/x.ts" flips to "Edited lib/x.ts"
|
|
567
|
+
// once the next action starts), same aggregation (repeat edits count up, read
|
|
568
|
+
// sweeps recede into one row), same loud failures. Change one, look at the
|
|
569
|
+
// other. The server re-validates every row (sanitizeProgress), so this side
|
|
570
|
+
// only has to be honest, not paranoid.
|
|
571
|
+
|
|
572
|
+
const MAX_ACTIVITY = 30;
|
|
573
|
+
|
|
574
|
+
/** @param {{kind: string, subject: string, status: string, n: number}} row */
|
|
575
|
+
function activitySentence(row) {
|
|
576
|
+
const running = row.status === "running";
|
|
577
|
+
switch (row.kind) {
|
|
578
|
+
case "edit":
|
|
579
|
+
return `${running ? "Editing" : "Edited"} ${row.subject || "files"}`;
|
|
580
|
+
case "read":
|
|
581
|
+
if (row.n > 1) return `${running ? "Reading" : "Read"} ${row.n} files`;
|
|
582
|
+
return `${running ? "Reading" : "Read"} ${row.subject || "a file"}`;
|
|
583
|
+
case "run": {
|
|
584
|
+
const label = describeRun(row.subject);
|
|
585
|
+
return running ? label : label.replace(/^Running/, "Ran");
|
|
586
|
+
}
|
|
587
|
+
default:
|
|
588
|
+
return row.subject; // note/result carry their own sentence
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* A bounded fold of AgentEvents into activity rows. Starts are the only ground
|
|
594
|
+
* truth a CLI stream carries, so "done" is the sequential-execution inference:
|
|
595
|
+
* a new action starting settles the one in flight. `settle("failed")` is for a
|
|
596
|
+
* dying run — leaving "Editing…" forever is the dishonesty this feed exists to
|
|
597
|
+
* avoid. session/phase events are metadata, not rows (same as stepLabel).
|
|
598
|
+
* @param {number} [limit]
|
|
599
|
+
*/
|
|
600
|
+
export function createActivityFold(limit = MAX_ACTIVITY) {
|
|
601
|
+
/** @type {{kind: string, subject: string, status: string, n: number}[]} */
|
|
602
|
+
const rows = [];
|
|
603
|
+
|
|
604
|
+
function settle(as = "done") {
|
|
605
|
+
const last = rows[rows.length - 1];
|
|
606
|
+
if (last && last.status === "running") last.status = as;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function add(row) {
|
|
610
|
+
settle();
|
|
611
|
+
rows.push(row);
|
|
612
|
+
if (rows.length > limit) rows.shift();
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** @param {AgentEvent} event */
|
|
616
|
+
function push(event) {
|
|
617
|
+
if (!event || typeof event !== "object") return;
|
|
618
|
+
const last = rows[rows.length - 1];
|
|
619
|
+
switch (event.t) {
|
|
620
|
+
case "edit":
|
|
621
|
+
if (last && last.status === "running" && last.kind === "edit" && last.subject === event.path) {
|
|
622
|
+
last.n += 1;
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
add({ kind: "edit", subject: event.path || "", status: "running", n: 1 });
|
|
626
|
+
return;
|
|
627
|
+
case "read":
|
|
628
|
+
if (last && last.status === "running" && last.kind === "read") {
|
|
629
|
+
last.n += 1;
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
add({ kind: "read", subject: event.path || "", status: "running", n: 1 });
|
|
633
|
+
return;
|
|
634
|
+
case "run":
|
|
635
|
+
add({ kind: "run", subject: event.cmd || "", status: "running", n: 1 });
|
|
636
|
+
return;
|
|
637
|
+
case "note":
|
|
638
|
+
if (!event.text) return;
|
|
639
|
+
add({ kind: "note", subject: event.text, status: "done", n: 1 });
|
|
640
|
+
return;
|
|
641
|
+
case "result":
|
|
642
|
+
add({
|
|
643
|
+
kind: "result",
|
|
644
|
+
subject: event.ok ? "Done" : (event.summary && event.summary.trim()) || "Finished with errors",
|
|
645
|
+
status: event.ok ? "done" : "failed",
|
|
646
|
+
n: 1,
|
|
647
|
+
});
|
|
648
|
+
return;
|
|
649
|
+
default:
|
|
650
|
+
return; // session / phase / unknown — metadata, not activity
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/** The wire rows, sentences formatted for the CURRENT statuses, sanitized.
|
|
655
|
+
* @returns {{kind: string, text: string, status: string, n?: number}[]} */
|
|
656
|
+
function list() {
|
|
657
|
+
const out = [];
|
|
658
|
+
for (const row of rows) {
|
|
659
|
+
const text = sanitizeText(activitySentence(row));
|
|
660
|
+
if (!text) continue;
|
|
661
|
+
const wire = { kind: row.kind, text, status: row.status };
|
|
662
|
+
if (row.kind === "edit" && row.n > 1) wire.n = row.n;
|
|
663
|
+
out.push(wire);
|
|
664
|
+
}
|
|
665
|
+
return out;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
return { push, settle, list };
|
|
669
|
+
}
|
package/src/cli.mjs
CHANGED
|
@@ -89,6 +89,33 @@ export function minimalEnv(base = process.env, extraAllow = []) {
|
|
|
89
89
|
return out;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
// ── PWD must match the directory we actually run in (0615) ───────────────────
|
|
93
|
+
// A child spawned with `cwd` still inherits the PARENT's `PWD`, which names
|
|
94
|
+
// wherever the daemon was launched. Most tools call getcwd() and never notice,
|
|
95
|
+
// but some resolve their working project from the environment instead: live-
|
|
96
|
+
// verified on opencode 1.18.5, which read, edited, and shelled in the daemon's
|
|
97
|
+
// launch directory while hilos staged the diff in the repo clone ("no changes
|
|
98
|
+
// produced"). An env that contradicts the real cwd is simply wrong, so every
|
|
99
|
+
// spawn that sets `cwd` also sets `PWD` to it — and drops the inherited
|
|
100
|
+
// `OLDPWD`, which is both meaningless to the child and a leak of where the
|
|
101
|
+
// daemon lives. No cwd means the child inherits ours, so PWD is left alone.
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* `base` with `PWD` pinned to `cwd` (and any inherited `OLDPWD` removed).
|
|
105
|
+
* Returns a copy; `base` is never mutated. A missing/blank `cwd` is a no-op.
|
|
106
|
+
* @param {Record<string, string | undefined>} base
|
|
107
|
+
* @param {string} [cwd]
|
|
108
|
+
* @returns {Record<string, string>}
|
|
109
|
+
*/
|
|
110
|
+
export function envForCwd(base, cwd) {
|
|
111
|
+
const out = { ...(base || {}) };
|
|
112
|
+
if (typeof cwd === "string" && cwd.trim()) {
|
|
113
|
+
out.PWD = cwd;
|
|
114
|
+
delete out.OLDPWD;
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
92
119
|
/** Human-readable elapsed time: "45s", "2m 3s". */
|
|
93
120
|
export function fmtElapsed(ms) {
|
|
94
121
|
const total = Math.max(0, Math.round(ms / 1000));
|
|
@@ -145,8 +172,9 @@ const MAX_CAPTURE_BYTES = 50 * 1024 * 1024;
|
|
|
145
172
|
* @property {(chunk: string) => void} [onData] - called with each stdout chunk as
|
|
146
173
|
* it arrives (lets a caller track the latest output line for a heartbeat)
|
|
147
174
|
* @property {Record<string, string>} [env] - base environment for the child. Any
|
|
148
|
-
* hilos-owned var (HILOS_*) is stripped from it regardless
|
|
149
|
-
*
|
|
175
|
+
* hilos-owned var (HILOS_*) is stripped from it regardless, and `PWD` is pinned
|
|
176
|
+
* to `cwd` when one is set (0615). Omit to inherit the daemon's environment
|
|
177
|
+
* minus HILOS_* (the safe default).
|
|
150
178
|
*/
|
|
151
179
|
|
|
152
180
|
/**
|
|
@@ -188,12 +216,13 @@ function runCliOnce(opts) {
|
|
|
188
216
|
// `codex exec` appends piped stdin to its prompt and blocks until EOF, so
|
|
189
217
|
// an open pipe hangs it until the run timeout ("Reading additional input
|
|
190
218
|
// from stdin…"). Nothing we spawn is ever fed via stdin.
|
|
191
|
-
// Always strip hilos's own token from the child's env (see scrubHilosEnv)
|
|
219
|
+
// Always strip hilos's own token from the child's env (see scrubHilosEnv),
|
|
220
|
+
// and keep PWD honest about the directory we run in (see envForCwd).
|
|
192
221
|
child = spawn(cmd, args, {
|
|
193
222
|
cwd,
|
|
194
223
|
detached: true,
|
|
195
224
|
stdio: ["ignore", "pipe", "pipe"],
|
|
196
|
-
env: scrubHilosEnv(env || process.env),
|
|
225
|
+
env: envForCwd(scrubHilosEnv(env || process.env), cwd),
|
|
197
226
|
});
|
|
198
227
|
} catch (error) {
|
|
199
228
|
resolve({ status: null, stdout: "", stderr: "", error });
|
package/src/config.mjs
CHANGED
|
@@ -57,6 +57,12 @@ const DEFAULTS = {
|
|
|
57
57
|
// it still won't run arbitrary commands. Override in hilos-agent.json if you
|
|
58
58
|
// want a stricter (or `--dangerously-skip-permissions`) command.
|
|
59
59
|
codingCmd: "claude -p --permission-mode acceptEdits",
|
|
60
|
+
// ACP transport opt-in (0759): drive the coding CLI over the Agent Client
|
|
61
|
+
// Protocol (JSON-RPC on stdio) instead of argv + stdout scraping. Covers
|
|
62
|
+
// opencode (`opencode acp`) and cursor (`cursor-agent acp`), and only for
|
|
63
|
+
// runs the workspace already gates with runtime permissions; every other
|
|
64
|
+
// run keeps the existing paths. Also enabled by HILOS_ACP=1.
|
|
65
|
+
acpTransport: false,
|
|
60
66
|
// Environment handed to the coding/chat CLI. hilos's own token (HILOS_*) is
|
|
61
67
|
// ALWAYS stripped either way. "inherit" (default) passes the rest of your
|
|
62
68
|
// shell env so the coding tool behaves exactly as if you ran it yourself.
|
|
@@ -124,6 +130,7 @@ export function resolveConfig({ flags = {}, join: joinPayload } = {}) {
|
|
|
124
130
|
chatTimeoutMs: process.env.HILOS_CHAT_TIMEOUT_MS ? Number(process.env.HILOS_CHAT_TIMEOUT_MS) : undefined,
|
|
125
131
|
backfill: process.env.HILOS_BACKFILL === "1" ? true : undefined,
|
|
126
132
|
once: process.env.HILOS_ONCE === "1" ? true : undefined,
|
|
133
|
+
acpTransport: process.env.HILOS_ACP === "1" ? true : undefined,
|
|
127
134
|
};
|
|
128
135
|
const merged = { ...DEFAULTS, ...file };
|
|
129
136
|
for (const [k, v] of Object.entries(env)) if (v !== undefined && v !== "") merged[k] = v;
|
|
@@ -155,6 +162,7 @@ export function resolveConfig({ flags = {}, join: joinPayload } = {}) {
|
|
|
155
162
|
// edited file can NEVER drop the daemon's connection.
|
|
156
163
|
const LIVE_FIELDS = [
|
|
157
164
|
"codingCmd",
|
|
165
|
+
"acpTransport",
|
|
158
166
|
"codingModel",
|
|
159
167
|
"chatCmd",
|
|
160
168
|
"codingEnv",
|
package/src/daemon.mjs
CHANGED
|
@@ -16,6 +16,29 @@ export function branchSlug(text, suffix) {
|
|
|
16
16
|
return `hilos/${base}-${suffix}`;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* How to push work when the coding child committed instead of leaving a diff.
|
|
21
|
+
*
|
|
22
|
+
* The daemon owns `intendedBranch`. A child that changes branches must not also
|
|
23
|
+
* change the PR head: if it switches to the configured base branch, using the
|
|
24
|
+
* child's current branch would ask GitHub to open `main` against `main`. Push
|
|
25
|
+
* the current HEAD commit under the daemon-owned branch name instead. Omitting
|
|
26
|
+
* `-u` on recovery also avoids rewriting the accidental branch's upstream.
|
|
27
|
+
*/
|
|
28
|
+
export function selfDrivenShipPlan(currentBranch, intendedBranch) {
|
|
29
|
+
const current = String(currentBranch || "").trim();
|
|
30
|
+
const intended = String(intendedBranch || "").trim();
|
|
31
|
+
if (!intended) throw new Error("An intended task branch is required");
|
|
32
|
+
const recovered = !current || current !== intended;
|
|
33
|
+
return {
|
|
34
|
+
headBranch: intended,
|
|
35
|
+
pushArgs: recovered
|
|
36
|
+
? ["push", "origin", `HEAD:refs/heads/${intended}`]
|
|
37
|
+
: ["push", "-u", "origin", intended],
|
|
38
|
+
recoveredFrom: recovered ? current || "detached HEAD" : null,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
19
42
|
/** Truncate a diff to a byte budget at a line boundary. */
|
|
20
43
|
export function truncateDiff(diff, maxBytes = 12000) {
|
|
21
44
|
if (diff.length <= maxBytes) return { text: diff, truncated: false, omittedLines: 0 };
|