harnery 0.9.0 → 0.11.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 +1 -0
- package/dist/commander.d.ts.map +1 -1
- package/dist/commander.js +6 -0
- package/dist/commands/agents.d.ts +24 -0
- package/dist/commands/agents.d.ts.map +1 -1
- package/dist/commands/agents.js +244 -13
- package/dist/commands/backup.d.ts +5 -4
- package/dist/commands/backup.d.ts.map +1 -1
- package/dist/commands/backup.js +15 -14
- package/dist/commands/browse.d.ts.map +1 -1
- package/dist/commands/browse.js +27 -1
- package/dist/commands/claude-desktop.d.ts +19 -0
- package/dist/commands/claude-desktop.d.ts.map +1 -0
- package/dist/commands/claude-desktop.js +168 -0
- package/dist/commands/context.d.ts.map +1 -1
- package/dist/commands/context.js +149 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +2 -3
- package/dist/commands/eml.d.ts +32 -0
- package/dist/commands/eml.d.ts.map +1 -1
- package/dist/commands/eml.js +16 -3
- package/dist/commands/harness.d.ts +11 -0
- package/dist/commands/harness.d.ts.map +1 -0
- package/dist/commands/harness.js +118 -0
- package/dist/commands/presence.d.ts +9 -4
- package/dist/commands/presence.d.ts.map +1 -1
- package/dist/commands/presence.js +88 -5
- package/dist/commands/relay.d.ts +9 -0
- package/dist/commands/relay.d.ts.map +1 -0
- package/dist/commands/relay.js +143 -0
- package/dist/commands/sync.d.ts.map +1 -1
- package/dist/commands/sync.js +5 -0
- package/dist/commands/workflow.d.ts.map +1 -1
- package/dist/commands/workflow.js +53 -17
- package/dist/core/agents/canonical-emit.d.ts +15 -0
- package/dist/core/agents/canonical-emit.d.ts.map +1 -1
- package/dist/core/agents/canonical-emit.js +41 -3
- package/dist/core/agents/cli.js +4 -2
- package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
- package/dist/core/agents/render/prompt-context.js +44 -3
- package/dist/core/agents/render/session-context.d.ts.map +1 -1
- package/dist/core/agents/render/session-context.js +26 -1
- package/dist/core/agents/rules/commit-conflict.d.ts.map +1 -1
- package/dist/core/agents/rules/commit-conflict.js +4 -37
- package/dist/core/agents/session-events.d.ts.map +1 -1
- package/dist/core/agents/session-events.js +16 -43
- package/dist/core/agents/state/heartbeat-projector.d.ts.map +1 -1
- package/dist/core/agents/state/heartbeat-projector.js +11 -2
- package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
- package/dist/core/agents/state/heartbeat-writer.js +3 -3
- package/dist/core/agents/state/names.d.ts +38 -3
- package/dist/core/agents/state/names.d.ts.map +1 -1
- package/dist/core/agents/state/names.js +46 -7
- package/dist/core/agents/state/pidmap.d.ts +5 -1
- package/dist/core/agents/state/pidmap.d.ts.map +1 -1
- package/dist/core/agents/state/pidmap.js +58 -2
- package/dist/core/agents/state/stale-sweep.d.ts +3 -2
- package/dist/core/agents/state/stale-sweep.d.ts.map +1 -1
- package/dist/core/agents/state/stale-sweep.js +6 -6
- package/dist/core/config.d.ts +68 -11
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +191 -25
- package/dist/core/context/index.d.ts +144 -0
- package/dist/core/context/index.d.ts.map +1 -0
- package/dist/core/context/index.js +380 -0
- package/dist/core/harnesses/bench.d.ts +29 -0
- package/dist/core/harnesses/bench.d.ts.map +1 -0
- package/dist/core/harnesses/bench.js +151 -0
- package/dist/core/harnesses/index.d.ts +9 -0
- package/dist/core/harnesses/index.d.ts.map +1 -0
- package/dist/core/harnesses/index.js +4 -0
- package/dist/core/harnesses/profiles.d.ts +62 -0
- package/dist/core/harnesses/profiles.d.ts.map +1 -0
- package/dist/core/harnesses/profiles.js +115 -0
- package/dist/core/harnesses/registry.d.ts +15 -0
- package/dist/core/harnesses/registry.d.ts.map +1 -0
- package/dist/core/harnesses/registry.js +113 -0
- package/dist/core/harnesses/types.d.ts +64 -0
- package/dist/core/harnesses/types.d.ts.map +1 -0
- package/dist/core/harnesses/types.js +20 -0
- package/dist/core/hooks/cli.js +280 -63
- package/dist/core/hooks/events/schema.d.ts +38 -1
- package/dist/core/hooks/events/schema.d.ts.map +1 -1
- package/dist/core/hooks/harness/events.d.ts.map +1 -1
- package/dist/core/hooks/harness/events.js +3 -0
- package/dist/core/hooks/harness/parse.d.ts +1 -1
- package/dist/core/hooks/harness/parse.d.ts.map +1 -1
- package/dist/core/hooks/harness/parse.js +4 -0
- package/dist/core/presence/blob.d.ts +40 -0
- package/dist/core/presence/blob.d.ts.map +1 -0
- package/dist/core/presence/blob.js +91 -0
- package/dist/core/presence/git.d.ts +68 -0
- package/dist/core/presence/git.d.ts.map +1 -0
- package/dist/core/presence/git.js +173 -0
- package/dist/core/presence/index.d.ts +74 -0
- package/dist/core/presence/index.d.ts.map +1 -0
- package/dist/core/presence/index.js +224 -0
- package/dist/core/presence/relay-client.d.ts +39 -0
- package/dist/core/presence/relay-client.d.ts.map +1 -0
- package/dist/core/presence/relay-client.js +295 -0
- package/dist/core/presence/relay-protocol.d.ts +94 -0
- package/dist/core/presence/relay-protocol.d.ts.map +1 -0
- package/dist/core/presence/relay-protocol.js +169 -0
- package/dist/core/workflow/billing.d.ts +1 -1
- package/dist/core/workflow/billing.d.ts.map +1 -1
- package/dist/core/workflow/billing.js +7 -1
- package/dist/core/workflow/engine.d.ts +5 -0
- package/dist/core/workflow/engine.d.ts.map +1 -1
- package/dist/core/workflow/engine.js +164 -16
- package/dist/core/workflow/harnesses.d.ts +3 -3
- package/dist/core/workflow/harnesses.d.ts.map +1 -1
- package/dist/core/workflow/harnesses.js +8 -17
- package/dist/core/workflow/index.d.ts +5 -0
- package/dist/core/workflow/index.d.ts.map +1 -0
- package/dist/core/workflow/index.js +3 -0
- package/dist/core/workflow/proof.d.ts +42 -0
- package/dist/core/workflow/proof.d.ts.map +1 -0
- package/dist/core/workflow/proof.js +342 -0
- package/dist/core/workflow/spawn-claude.d.ts +4 -1
- package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
- package/dist/core/workflow/spawn-claude.js +39 -18
- package/dist/core/workflow/spawn-codex.d.ts +4 -1
- package/dist/core/workflow/spawn-codex.d.ts.map +1 -1
- package/dist/core/workflow/spawn-codex.js +44 -22
- package/dist/core/workflow/spawn-cursor.d.ts +4 -1
- package/dist/core/workflow/spawn-cursor.d.ts.map +1 -1
- package/dist/core/workflow/spawn-cursor.js +35 -16
- package/dist/core/workflow/types.d.ts +156 -2
- package/dist/core/workflow/types.d.ts.map +1 -1
- package/dist/core/workflow/types.js +1 -1
- package/dist/lib/browser/client.d.ts +16 -0
- package/dist/lib/browser/client.d.ts.map +1 -1
- package/dist/lib/browser/client.js +22 -0
- package/dist/lib/browser/index.d.ts +1 -0
- package/dist/lib/browser/index.d.ts.map +1 -1
- package/dist/lib/browser/index.js +1 -0
- package/dist/lib/browser/launch-args.d.ts +21 -0
- package/dist/lib/browser/launch-args.d.ts.map +1 -0
- package/dist/lib/browser/launch-args.js +32 -0
- package/dist/lib/claude-desktop.d.ts +105 -0
- package/dist/lib/claude-desktop.d.ts.map +1 -0
- package/dist/lib/claude-desktop.js +217 -0
- package/dist/lib/docs-lint.d.ts.map +1 -1
- package/dist/lib/docs-lint.js +5 -2
- package/dist/lib/identities/assume.d.ts +51 -0
- package/dist/lib/identities/assume.d.ts.map +1 -0
- package/dist/lib/identities/assume.js +274 -0
- package/dist/lib/identities/index.d.ts +7 -7
- package/dist/lib/identities/index.d.ts.map +1 -1
- package/dist/lib/identities/index.js +24 -24
- package/dist/lib/instructions/templates.d.ts.map +1 -1
- package/dist/lib/instructions/templates.js +15 -2
- package/package.json +22 -6
- package/schemas/config.schema.json +88 -19
- package/src/commander.ts +6 -0
- package/src/commands/agents.ts +280 -14
- package/src/commands/backup.ts +17 -16
- package/src/commands/browse.ts +32 -0
- package/src/commands/claude-desktop.ts +215 -0
- package/src/commands/context.ts +174 -1
- package/src/commands/doctor.ts +2 -3
- package/src/commands/eml.ts +22 -4
- package/src/commands/harness.ts +147 -0
- package/src/commands/presence.ts +111 -5
- package/src/commands/relay.ts +160 -0
- package/src/commands/sync.ts +4 -0
- package/src/commands/workflow.ts +62 -19
- package/src/core/agents/canonical-emit.ts +41 -3
- package/src/core/agents/cli.ts +4 -2
- package/src/core/agents/render/prompt-context.ts +48 -3
- package/src/core/agents/render/session-context.ts +27 -1
- package/src/core/agents/rules/commit-conflict.ts +4 -33
- package/src/core/agents/session-events.ts +15 -39
- package/src/core/agents/state/heartbeat-projector.ts +10 -1
- package/src/core/agents/state/heartbeat-writer.ts +3 -2
- package/src/core/agents/state/names.ts +68 -9
- package/src/core/agents/state/pidmap.ts +59 -2
- package/src/core/agents/state/stale-sweep.ts +6 -11
- package/src/core/config.ts +248 -24
- package/src/core/context/index.ts +575 -0
- package/src/core/harnesses/bench.ts +214 -0
- package/src/core/harnesses/index.ts +31 -0
- package/src/core/harnesses/profiles.ts +141 -0
- package/src/core/harnesses/registry.ts +140 -0
- package/src/core/harnesses/types.ts +90 -0
- package/src/core/hooks/cli.ts +331 -62
- package/src/core/hooks/events/schema.ts +68 -0
- package/src/core/hooks/harness/events.ts +3 -0
- package/src/core/hooks/harness/parse.ts +7 -1
- package/src/core/presence/blob.ts +125 -0
- package/src/core/presence/git.ts +191 -0
- package/src/core/presence/index.ts +274 -0
- package/src/core/presence/relay-client.ts +322 -0
- package/src/core/presence/relay-protocol.ts +246 -0
- package/src/core/workflow/billing.ts +8 -2
- package/src/core/workflow/engine.ts +182 -16
- package/src/core/workflow/harnesses.ts +14 -19
- package/src/core/workflow/index.ts +43 -0
- package/src/core/workflow/proof.ts +434 -0
- package/src/core/workflow/spawn-claude.ts +39 -20
- package/src/core/workflow/spawn-codex.ts +47 -28
- package/src/core/workflow/spawn-cursor.ts +37 -18
- package/src/core/workflow/types.ts +176 -2
- package/src/lib/browser/client.ts +37 -0
- package/src/lib/browser/index.ts +1 -0
- package/src/lib/browser/launch-args.ts +33 -0
- package/src/lib/claude-desktop.ts +293 -0
- package/src/lib/docs-lint.ts +5 -2
- package/src/lib/identities/assume.ts +363 -0
- package/src/lib/identities/index.ts +28 -24
- package/src/lib/instructions/templates.ts +16 -2
package/src/commands/agents.ts
CHANGED
|
@@ -39,7 +39,8 @@ import {
|
|
|
39
39
|
resolveOwnerBySessionEnv,
|
|
40
40
|
resolveOwnerWithSource,
|
|
41
41
|
} from "../core/agents/index.ts";
|
|
42
|
-
import { resolveBinName } from "../core/config.ts";
|
|
42
|
+
import { coordFreshnessSeconds, resolveBinName } from "../core/config.ts";
|
|
43
|
+
import { type RemoteMachine, readRemoteMachines } from "../core/presence/index.ts";
|
|
43
44
|
|
|
44
45
|
/** Cap for CLI scans of the unbounded event ledger (`trace` / `health`). Well
|
|
45
46
|
* under V8's ~512MB max string length so a `readFileSync` of the whole file can
|
|
@@ -75,6 +76,7 @@ import {
|
|
|
75
76
|
writeManifest,
|
|
76
77
|
writePrompt,
|
|
77
78
|
} from "../lib/council/index.ts";
|
|
79
|
+
import { assumeIdentity, IdentityAssumeError } from "../lib/identities/assume.ts";
|
|
78
80
|
import {
|
|
79
81
|
displayName as displayAgentName,
|
|
80
82
|
ensureIdentity,
|
|
@@ -83,13 +85,36 @@ import {
|
|
|
83
85
|
lookupByName as lookupIdentityByName,
|
|
84
86
|
} from "../lib/identities/index.ts";
|
|
85
87
|
|
|
86
|
-
|
|
88
|
+
/**
|
|
89
|
+
* Heartbeat-freshness window (seconds). Reads `coord.freshness_seconds` /
|
|
90
|
+
* `HARNERY_AGENT_COORD_FRESHNESS` via the shared accessor (defaults to 600 =
|
|
91
|
+
* 10 min). A function, not a const, so a config edit takes effect without a
|
|
92
|
+
* process restart (mtime-cached).
|
|
93
|
+
*/
|
|
94
|
+
function freshnessCutoffSecs(): number {
|
|
95
|
+
return coordFreshnessSeconds();
|
|
96
|
+
}
|
|
87
97
|
|
|
88
98
|
const SUBAGENT_NOTE =
|
|
89
99
|
"Bash identity is process-level in v1; if you're running inside a subagent, " +
|
|
90
100
|
"this resolves to the parent group's name, not the subagent's. A subagent-aware " +
|
|
91
101
|
"bridge (per-shell marker file at .harnery/shells/<pid>) is out of scope.";
|
|
92
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Spawn options for agent-coord child processes: pin the coord root the
|
|
105
|
+
* command already resolved (git-superproject-aware via `monorepoRoot()`), so
|
|
106
|
+
* the helper can't re-resolve a DIFFERENT root by walking up from a drifted
|
|
107
|
+
* shell cwd. The concrete failure this prevents: a shell cd'd into an
|
|
108
|
+
* embedded harnery checkout (which carries its own committed `.harnery/`)
|
|
109
|
+
* made agent-coord resolve that nested root and miss the session's real
|
|
110
|
+
* heartbeat — `set-task: no heartbeat at .harnery/active/<id>.json` while
|
|
111
|
+
* `status` (which resolves in-process) worked fine. Mirrors the hooks side's
|
|
112
|
+
* `childEnv()`; every agent-coord spawn must carry this.
|
|
113
|
+
*/
|
|
114
|
+
export function coordHelperOpts(root: string): { cwd: string; env: NodeJS.ProcessEnv } {
|
|
115
|
+
return { cwd: root, env: { ...process.env, HARNERY_COORD_ROOT_OVERRIDE: root } };
|
|
116
|
+
}
|
|
117
|
+
|
|
93
118
|
function formatPlatformLabel(platform?: string | null): string {
|
|
94
119
|
if (platform === "cursor") return "Cursor";
|
|
95
120
|
if (platform === "codex") return "Codex";
|
|
@@ -98,10 +123,12 @@ function formatPlatformLabel(platform?: string | null): string {
|
|
|
98
123
|
|
|
99
124
|
interface Row {
|
|
100
125
|
name: string;
|
|
126
|
+
/** Durable persona UUID after an explicit identity assumption. */
|
|
127
|
+
agent_id?: string | null;
|
|
101
128
|
instance_id: string;
|
|
102
129
|
session_id: string;
|
|
103
130
|
kind: string;
|
|
104
|
-
relation: "self" | "group" | "blocks" | "unknown";
|
|
131
|
+
relation: "self" | "group" | "blocks" | "remote" | "unknown";
|
|
105
132
|
started_at: string;
|
|
106
133
|
last_heartbeat: string;
|
|
107
134
|
files_touched: string[];
|
|
@@ -111,6 +138,9 @@ interface Row {
|
|
|
111
138
|
turn_summary?: string | null;
|
|
112
139
|
turn_summary_updated_at?: string | null;
|
|
113
140
|
platform?: string | null;
|
|
141
|
+
/** Set on relation=remote rows: the machine label the row arrived from
|
|
142
|
+
* via the cross-machine presence transport (ADR 0016). */
|
|
143
|
+
machine?: string | null;
|
|
114
144
|
}
|
|
115
145
|
|
|
116
146
|
let emit: EmitContext;
|
|
@@ -153,6 +183,23 @@ export function registerAgentsCommand(program: Command, emitParam: EmitContext):
|
|
|
153
183
|
runStatus(opts);
|
|
154
184
|
});
|
|
155
185
|
|
|
186
|
+
cmd
|
|
187
|
+
.command("suggest-name [description...]")
|
|
188
|
+
.description(
|
|
189
|
+
'Reprint a session name ("Agent <you> - <description>") for the operator to set as their ' +
|
|
190
|
+
"harness tab title. With no arg, derives it from your current task. The primary naming path " +
|
|
191
|
+
"is set-task (it suggests a name on the first focus declaration); reach for this to reprint " +
|
|
192
|
+
"or re-suggest after a topic pivot. Read-only.",
|
|
193
|
+
)
|
|
194
|
+
.option("--json", "JSON output instead of the bare name")
|
|
195
|
+
.option(
|
|
196
|
+
"--session-id <id>",
|
|
197
|
+
"Lookup heartbeat by session_id directly, bypassing the ppid walk.",
|
|
198
|
+
)
|
|
199
|
+
.action((description: string[], opts: { json?: boolean; sessionId?: string }) => {
|
|
200
|
+
runSuggestName(description, opts);
|
|
201
|
+
});
|
|
202
|
+
|
|
156
203
|
cmd
|
|
157
204
|
.command("watch")
|
|
158
205
|
.description(
|
|
@@ -445,6 +492,63 @@ function registerIdentityCommands(parent: Command): void {
|
|
|
445
492
|
meta: { action: "identity-ensure" },
|
|
446
493
|
});
|
|
447
494
|
});
|
|
495
|
+
|
|
496
|
+
identity
|
|
497
|
+
.command("assume <name-or-id>")
|
|
498
|
+
.description(
|
|
499
|
+
"Bind this live session to a durable persona. Reclaims an abandoned " +
|
|
500
|
+
"local namesake (no live process); refuses when another live process " +
|
|
501
|
+
"or known remote session still holds the name. Updates name history, " +
|
|
502
|
+
"the event ledger, and the heartbeat.",
|
|
503
|
+
)
|
|
504
|
+
.option("--json", "JSON envelope output")
|
|
505
|
+
.option(
|
|
506
|
+
"--session-id <id>",
|
|
507
|
+
"Bind the heartbeat with this session_id directly, bypassing the ppid walk.",
|
|
508
|
+
)
|
|
509
|
+
.action((target: string, opts: { json?: boolean; sessionId?: string }) => {
|
|
510
|
+
runIdentityAssume(target, opts);
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function runIdentityAssume(target: string, opts: { json?: boolean; sessionId?: string }): void {
|
|
515
|
+
if (opts.json) emit.config({ format: "json" });
|
|
516
|
+
const root = monorepoRoot();
|
|
517
|
+
if (!root) {
|
|
518
|
+
emit.error({
|
|
519
|
+
code: "not_in_repo",
|
|
520
|
+
message: "not in an agent session; coord_root() returned null",
|
|
521
|
+
});
|
|
522
|
+
process.exit(1);
|
|
523
|
+
}
|
|
524
|
+
if (!opts.sessionId) ensureCursorSession(root);
|
|
525
|
+
const owner = opts.sessionId ?? resolveOwner();
|
|
526
|
+
if (!owner) {
|
|
527
|
+
emit.error({
|
|
528
|
+
code: "no_pidmap_entry",
|
|
529
|
+
message:
|
|
530
|
+
"not in an agent session; ppid walk found no pid-map entry (pass --session-id to bypass)",
|
|
531
|
+
});
|
|
532
|
+
process.exit(1);
|
|
533
|
+
}
|
|
534
|
+
try {
|
|
535
|
+
const result = assumeIdentity(root, owner, target);
|
|
536
|
+
emit.data({
|
|
537
|
+
...result,
|
|
538
|
+
display_name: displayAgentName(result.name),
|
|
539
|
+
action: "identity-assume",
|
|
540
|
+
});
|
|
541
|
+
} catch (error) {
|
|
542
|
+
if (error instanceof IdentityAssumeError) {
|
|
543
|
+
emit.error({ code: error.code, message: error.message });
|
|
544
|
+
} else {
|
|
545
|
+
emit.error({
|
|
546
|
+
code: "identity_assume_failed",
|
|
547
|
+
message: error instanceof Error ? error.message : String(error),
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
process.exit(1);
|
|
551
|
+
}
|
|
448
552
|
}
|
|
449
553
|
|
|
450
554
|
function registerCouncilCommands(parent: Command): void {
|
|
@@ -716,6 +820,7 @@ function runWhoami(opts: { json?: boolean }): void {
|
|
|
716
820
|
|
|
717
821
|
const row: Row = {
|
|
718
822
|
name: hb.name || "unknown",
|
|
823
|
+
agent_id: hb.agent_id || null,
|
|
719
824
|
instance_id: hb.instance_id,
|
|
720
825
|
session_id: hb.session_id,
|
|
721
826
|
kind: normalizeKind(hb.kind),
|
|
@@ -779,7 +884,7 @@ function runList(opts: { all?: boolean; stale?: boolean; json?: boolean }): void
|
|
|
779
884
|
|
|
780
885
|
// Apply staleness filter unless --stale.
|
|
781
886
|
const nowSec = Math.floor(Date.now() / 1000);
|
|
782
|
-
const cutoff = nowSec -
|
|
887
|
+
const cutoff = nowSec - freshnessCutoffSecs();
|
|
783
888
|
const live = opts.stale
|
|
784
889
|
? heartbeats
|
|
785
890
|
: heartbeats.filter((h) => {
|
|
@@ -813,6 +918,7 @@ function runList(opts: { all?: boolean; stale?: boolean; json?: boolean }): void
|
|
|
813
918
|
// Orphan transient case
|
|
814
919
|
rows.push({
|
|
815
920
|
name: h.name || "unknown",
|
|
921
|
+
agent_id: h.agent_id || null,
|
|
816
922
|
instance_id: h.instance_id,
|
|
817
923
|
session_id: h.session_id,
|
|
818
924
|
kind: "transient",
|
|
@@ -837,6 +943,7 @@ function runList(opts: { all?: boolean; stale?: boolean; json?: boolean }): void
|
|
|
837
943
|
}
|
|
838
944
|
rows.push({
|
|
839
945
|
name: h.name || "unknown",
|
|
946
|
+
agent_id: h.agent_id || null,
|
|
840
947
|
instance_id: h.instance_id,
|
|
841
948
|
session_id: h.session_id,
|
|
842
949
|
kind: normalizeKind(h.kind),
|
|
@@ -858,6 +965,31 @@ function runList(opts: { all?: boolean; stale?: boolean; json?: boolean }): void
|
|
|
858
965
|
// what made `harn agents list --all --stale` crash.
|
|
859
966
|
rows.sort((a, b) => (a.started_at ?? "").localeCompare(b.started_at ?? ""));
|
|
860
967
|
|
|
968
|
+
// Cross-machine presence (ADR 0016): append sessions on OTHER machines from
|
|
969
|
+
// the locally-fetched presence refs. Advisory rows (relation=remote) — they
|
|
970
|
+
// don't participate in local claim blocking in v1.
|
|
971
|
+
for (const rm of readRemoteMachines(root)) {
|
|
972
|
+
for (const a of rm.agents) {
|
|
973
|
+
rows.push({
|
|
974
|
+
name: a.name || "unknown",
|
|
975
|
+
instance_id: a.instance_id,
|
|
976
|
+
session_id: a.session_id ?? a.instance_id,
|
|
977
|
+
kind: normalizeKind(a.kind),
|
|
978
|
+
relation: "remote",
|
|
979
|
+
started_at: a.started_at ?? "",
|
|
980
|
+
last_heartbeat: a.last_heartbeat ?? "",
|
|
981
|
+
files_touched: [...(a.files_touched ?? [])].sort(),
|
|
982
|
+
last_tool: a.last_tool ?? null,
|
|
983
|
+
last_tool_target: null,
|
|
984
|
+
task: a.task ?? null,
|
|
985
|
+
turn_summary: a.turn_summary ?? null,
|
|
986
|
+
turn_summary_updated_at: null,
|
|
987
|
+
platform: a.platform ?? "claude_code",
|
|
988
|
+
machine: rm.machine,
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
|
|
861
993
|
// Emit. JSON format gets {rows, note}; TTY gets the rows with note as a footnote.
|
|
862
994
|
emit.data({ rows, note: SUBAGENT_NOTE });
|
|
863
995
|
if (process.stdout.isTTY && !opts.json) {
|
|
@@ -976,7 +1108,7 @@ async function runWatch(pollMs: number): Promise<void> {
|
|
|
976
1108
|
function listActiveHeartbeats(activeDir: string): Heartbeat[] {
|
|
977
1109
|
const out: Heartbeat[] = [];
|
|
978
1110
|
const nowSec = Math.floor(Date.now() / 1000);
|
|
979
|
-
const cutoff = nowSec -
|
|
1111
|
+
const cutoff = nowSec - freshnessCutoffSecs();
|
|
980
1112
|
for (const file of readdirSync(activeDir)) {
|
|
981
1113
|
if (!file.endsWith(".json")) continue;
|
|
982
1114
|
try {
|
|
@@ -1020,7 +1152,7 @@ async function runShow(name: string, opts: { json?: boolean }): Promise<void> {
|
|
|
1020
1152
|
// Read all heartbeats; match by name (case-insensitive). Apply freshness filter.
|
|
1021
1153
|
const matches: Heartbeat[] = [];
|
|
1022
1154
|
const nowSec = Math.floor(Date.now() / 1000);
|
|
1023
|
-
const cutoff = nowSec -
|
|
1155
|
+
const cutoff = nowSec - freshnessCutoffSecs();
|
|
1024
1156
|
for (const file of readdirSync(activeDir)) {
|
|
1025
1157
|
if (!file.endsWith(".json")) continue;
|
|
1026
1158
|
try {
|
|
@@ -1185,6 +1317,11 @@ function shouldBootstrapCursorSession(): boolean {
|
|
|
1185
1317
|
|
|
1186
1318
|
function ensureCursorSession(root: string): void {
|
|
1187
1319
|
if (!shouldBootstrapCursorSession()) return;
|
|
1320
|
+
// Explicit owner override means the caller already knows who they are.
|
|
1321
|
+
// Bootstrapping sessionStart inherits that env into the child hook, which
|
|
1322
|
+
// re-projects a fresh heartbeat onto the override owner and can wipe an
|
|
1323
|
+
// assumed persona agent_id (seen under Cursor integration fixtures).
|
|
1324
|
+
if (process.env.HARNERY_AGENT_COORD_OWNER?.trim()) return;
|
|
1188
1325
|
if (resolveOwnerBySessionEnv(root)) return;
|
|
1189
1326
|
|
|
1190
1327
|
const sessionId = cursorEnvSessionId();
|
|
@@ -1242,6 +1379,7 @@ function runReleaseClaim(path: string): void {
|
|
|
1242
1379
|
const helper = resolve(root, "harnery", "bin", "agent-coord");
|
|
1243
1380
|
const result = spawnSync(helper, ["release-claim", myOwner, canonical], {
|
|
1244
1381
|
encoding: "utf8",
|
|
1382
|
+
...coordHelperOpts(root),
|
|
1245
1383
|
});
|
|
1246
1384
|
if (result.status !== 0) {
|
|
1247
1385
|
emit.error({
|
|
@@ -1276,10 +1414,21 @@ function runSetTask(task: string, opts?: { sessionId?: string }): void {
|
|
|
1276
1414
|
process.exit(1);
|
|
1277
1415
|
}
|
|
1278
1416
|
|
|
1417
|
+
// Snapshot BEFORE the mutation: a heartbeat that has never carried a task
|
|
1418
|
+
// has no `task_updated_at` stamp (setTask always stamps it, even on a clear),
|
|
1419
|
+
// so its absence is the "focus never declared this session" signal — i.e. the
|
|
1420
|
+
// first set-task of the session. That's the natural moment to also hand the
|
|
1421
|
+
// operator a session name built from the same declaration (one source of
|
|
1422
|
+
// truth: the focus you declare names the session). Distinct from task===""
|
|
1423
|
+
// (a deliberate clear), which carries a stamp and does NOT re-trigger.
|
|
1424
|
+
const priorHb = readHeartbeat(myOwner);
|
|
1425
|
+
const firstOfSession = !priorHb?.task_updated_at;
|
|
1426
|
+
|
|
1279
1427
|
// Heartbeat mutation goes through agent-coord (atomic temp+rename).
|
|
1280
1428
|
const helper = resolve(root, "harnery", "bin", "agent-coord");
|
|
1281
1429
|
const result = spawnSync(helper, ["set-task", myOwner, task], {
|
|
1282
1430
|
encoding: "utf8",
|
|
1431
|
+
...coordHelperOpts(root),
|
|
1283
1432
|
});
|
|
1284
1433
|
if (result.status !== 0) {
|
|
1285
1434
|
emit.error({
|
|
@@ -1298,14 +1447,102 @@ function runSetTask(task: string, opts?: { sessionId?: string }): void {
|
|
|
1298
1447
|
data: { task, cleared: !task || task.length === 0 },
|
|
1299
1448
|
});
|
|
1300
1449
|
|
|
1450
|
+
// On the first focus declaration of a session, surface a suggested session
|
|
1451
|
+
// name for the operator to set as their harness tab title. Read the result
|
|
1452
|
+
// (`suggested_session_name`) and reproduce it in a fenced code block so the
|
|
1453
|
+
// UI's Copy button hands over the exact string. null on every later call.
|
|
1454
|
+
const built =
|
|
1455
|
+
firstOfSession && !opts?.sessionId ? buildSuggestedName(hb?.name ?? "unknown", [task]) : null;
|
|
1456
|
+
|
|
1301
1457
|
emit.data({
|
|
1302
1458
|
instance_id: myOwner,
|
|
1303
1459
|
name: hb?.name ?? null,
|
|
1304
1460
|
task: hb?.task ?? null,
|
|
1305
1461
|
cleared: !task || task.length === 0,
|
|
1462
|
+
first_of_session: firstOfSession,
|
|
1463
|
+
suggested_session_name: built?.suggestedName ?? null,
|
|
1306
1464
|
});
|
|
1307
1465
|
}
|
|
1308
1466
|
|
|
1467
|
+
/**
|
|
1468
|
+
* Build the copy-pasteable session name from the coord identity + the agent's
|
|
1469
|
+
* description parts. Pure (no coord-state reads) so it's unit-testable; collapses
|
|
1470
|
+
* internal whitespace and trims. Returns null when the description is empty.
|
|
1471
|
+
*/
|
|
1472
|
+
export function buildSuggestedName(
|
|
1473
|
+
agentName: string,
|
|
1474
|
+
descriptionParts: string[],
|
|
1475
|
+
): { suggestedName: string; description: string } | null {
|
|
1476
|
+
const description = descriptionParts.join(" ").replace(/\s+/g, " ").trim();
|
|
1477
|
+
if (!description) return null;
|
|
1478
|
+
const name = agentName?.trim() || "unknown";
|
|
1479
|
+
return { suggestedName: `Agent ${name} - ${description}`, description };
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
function runSuggestName(
|
|
1483
|
+
descriptionParts: string[],
|
|
1484
|
+
opts: { json?: boolean; sessionId?: string },
|
|
1485
|
+
): void {
|
|
1486
|
+
const root = monorepoRoot();
|
|
1487
|
+
if (!root) {
|
|
1488
|
+
emit.error({
|
|
1489
|
+
code: "not_in_repo",
|
|
1490
|
+
message: "not in an agent session; coord_root() returned null",
|
|
1491
|
+
});
|
|
1492
|
+
process.exit(1);
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
// Identity: prefer explicit --session-id (ppid-walk-free escape hatch, mirrors
|
|
1496
|
+
// `status`/`set-task`), fall back to the ppid walk for interactive shell use.
|
|
1497
|
+
if (!opts.sessionId) ensureCursorSession(root);
|
|
1498
|
+
const myOwner = opts.sessionId ?? resolveOwner();
|
|
1499
|
+
if (!myOwner) {
|
|
1500
|
+
emit.error({
|
|
1501
|
+
code: "no_pidmap_entry",
|
|
1502
|
+
message:
|
|
1503
|
+
"not in an agent session; ppid walk found no pid-map entry (pass --session-id to bypass)",
|
|
1504
|
+
});
|
|
1505
|
+
process.exit(1);
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
const hb = readHeartbeat(myOwner);
|
|
1509
|
+
const agentName = hb?.name || "unknown";
|
|
1510
|
+
// Description resolution: an explicit arg wins; with no arg, fall back to the
|
|
1511
|
+
// agent's current declared task, so a bare `suggest-name` reprints the running
|
|
1512
|
+
// session's name (handy after a topic pivot). Read-only — mutates no state;
|
|
1513
|
+
// the primary naming path is set-task's first-of-session suggestion.
|
|
1514
|
+
const parts = descriptionParts.length > 0 ? descriptionParts : hb?.task ? [hb.task] : [];
|
|
1515
|
+
const built = buildSuggestedName(agentName, parts);
|
|
1516
|
+
if (!built) {
|
|
1517
|
+
emit.error({
|
|
1518
|
+
code: "no_description",
|
|
1519
|
+
message:
|
|
1520
|
+
'pass a 2-5 word topic (e.g. suggest-name "Auth Refactor"), or declare one first with set-task',
|
|
1521
|
+
});
|
|
1522
|
+
process.exit(1);
|
|
1523
|
+
}
|
|
1524
|
+
const { suggestedName, description } = built;
|
|
1525
|
+
const displayName = `agent-${agentName}`;
|
|
1526
|
+
|
|
1527
|
+
if (opts.json) {
|
|
1528
|
+
emit.config({ format: "json" });
|
|
1529
|
+
emit.data({
|
|
1530
|
+
name: displayName,
|
|
1531
|
+
suggested_session_name: suggestedName,
|
|
1532
|
+
agent_name: agentName,
|
|
1533
|
+
description,
|
|
1534
|
+
});
|
|
1535
|
+
return;
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
// Emit the bare name only — no box. The agent reproduces this exact string in
|
|
1539
|
+
// a fenced code block in its reply, which every chat UI decorates with a
|
|
1540
|
+
// one-click Copy button so the operator can grab it without hand-selecting.
|
|
1541
|
+
// (A drawn box would copy its borders + labels too.) Direct write, not
|
|
1542
|
+
// emit.text: runs via Bash with no TTY, same chat-paste contract as runStatus.
|
|
1543
|
+
process.stdout.write(`${suggestedName}\n`); // lint-ok-emission: chat-paste path
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1309
1546
|
function runStatus(opts: { json?: boolean; sessionId?: string }): void {
|
|
1310
1547
|
const root = monorepoRoot();
|
|
1311
1548
|
if (!root) {
|
|
@@ -1347,6 +1584,7 @@ function runStatus(opts: { json?: boolean; sessionId?: string }): void {
|
|
|
1347
1584
|
spawnSync(helper, ["stamp-status-call", myOwner], {
|
|
1348
1585
|
encoding: "utf8",
|
|
1349
1586
|
timeout: 2000,
|
|
1587
|
+
...coordHelperOpts(root),
|
|
1350
1588
|
});
|
|
1351
1589
|
} catch {
|
|
1352
1590
|
/* non-fatal */
|
|
@@ -1370,7 +1608,7 @@ function runStatus(opts: { json?: boolean; sessionId?: string }): void {
|
|
|
1370
1608
|
|
|
1371
1609
|
const activeDir = resolve(root, ".harnery", "active");
|
|
1372
1610
|
const nowSec = Math.floor(Date.now() / 1000);
|
|
1373
|
-
const cutoff = nowSec -
|
|
1611
|
+
const cutoff = nowSec - freshnessCutoffSecs();
|
|
1374
1612
|
const livePeers: Heartbeat[] = [];
|
|
1375
1613
|
let peersStale = 0;
|
|
1376
1614
|
if (existsSync(activeDir)) {
|
|
@@ -1407,7 +1645,9 @@ function runStatus(opts: { json?: boolean; sessionId?: string }): void {
|
|
|
1407
1645
|
4,
|
|
1408
1646
|
"0 held",
|
|
1409
1647
|
);
|
|
1410
|
-
|
|
1648
|
+
// Cross-machine presence (ADR 0016): sessions on other machines, advisory.
|
|
1649
|
+
const remoteMachines = readRemoteMachines(root);
|
|
1650
|
+
const peersStr = formatPeers(livePeers, 4, peersStale, remoteMachines);
|
|
1411
1651
|
|
|
1412
1652
|
const ctxUsage = readContextUsage(hb.session_id, hb.platform);
|
|
1413
1653
|
let ctxStr: string;
|
|
@@ -1449,6 +1689,15 @@ function runStatus(opts: { json?: boolean; sessionId?: string }): void {
|
|
|
1449
1689
|
name: p.name || "unnamed",
|
|
1450
1690
|
files: p.files_touched?.length ?? 0,
|
|
1451
1691
|
})),
|
|
1692
|
+
remote_machines: remoteMachines.map((m) => ({
|
|
1693
|
+
machine: m.machine,
|
|
1694
|
+
age_secs: m.age_secs,
|
|
1695
|
+
agents: m.agents.map((a) => ({
|
|
1696
|
+
name: a.name || "unnamed",
|
|
1697
|
+
task: a.task ?? null,
|
|
1698
|
+
files: a.files_touched?.length ?? 0,
|
|
1699
|
+
})),
|
|
1700
|
+
})),
|
|
1452
1701
|
pending_councils: pendingCouncils,
|
|
1453
1702
|
context_used: ctxUsage?.used ?? null,
|
|
1454
1703
|
context_window: ctxUsage?.window ?? null,
|
|
@@ -1499,8 +1748,13 @@ function formatList(items: string[], cap: number, emptyLabel: string): string {
|
|
|
1499
1748
|
return `${shown}, +${items.length - cap} more`;
|
|
1500
1749
|
}
|
|
1501
1750
|
|
|
1502
|
-
function formatPeers(
|
|
1503
|
-
|
|
1751
|
+
function formatPeers(
|
|
1752
|
+
peers: Heartbeat[],
|
|
1753
|
+
cap: number,
|
|
1754
|
+
staleCount: number,
|
|
1755
|
+
remoteMachines: RemoteMachine[] = [],
|
|
1756
|
+
): string {
|
|
1757
|
+
if (peers.length === 0 && staleCount === 0 && remoteMachines.length === 0) return "none";
|
|
1504
1758
|
const labels = peers.map((p) => {
|
|
1505
1759
|
const name = p.name || "unnamed";
|
|
1506
1760
|
const plat = formatPlatformLabel(p.platform);
|
|
@@ -1516,7 +1770,16 @@ function formatPeers(peers: Heartbeat[], cap: number, staleCount: number): strin
|
|
|
1516
1770
|
} else {
|
|
1517
1771
|
main = `${labels.slice(0, cap).join(", ")}, +${labels.length - cap} more`;
|
|
1518
1772
|
}
|
|
1519
|
-
|
|
1773
|
+
if (staleCount > 0) main = `${main}; ${staleCount} stale`;
|
|
1774
|
+
// Remote machines (presence transport): `Name @machine` labels, capped.
|
|
1775
|
+
if (remoteMachines.length > 0) {
|
|
1776
|
+
const remote = remoteMachines
|
|
1777
|
+
.flatMap((m) => m.agents.map((a) => `${a.name || "unnamed"} @${m.machine}`))
|
|
1778
|
+
.slice(0, cap);
|
|
1779
|
+
const extra = remoteMachines.reduce((n, m) => n + m.agents.length, 0) - remote.length;
|
|
1780
|
+
main = `${main}; ${remote.join(", ")}${extra > 0 ? `, +${extra} more` : ""}`;
|
|
1781
|
+
}
|
|
1782
|
+
return main;
|
|
1520
1783
|
}
|
|
1521
1784
|
|
|
1522
1785
|
function fmtTokens(n: number): string {
|
|
@@ -2213,6 +2476,9 @@ function traceLine(ev: CanonicalEvent, allTools: boolean): TraceEntry | null {
|
|
|
2213
2476
|
case "state.status_checked":
|
|
2214
2477
|
detail = "status box rendered";
|
|
2215
2478
|
break;
|
|
2479
|
+
case "identity.assumed":
|
|
2480
|
+
detail = `${s("previous_name") || "unknown"} → ${s("name") || "unknown"}${s("agent_id") ? ` · persona=${s("agent_id").slice(0, 8)}…` : ""}`;
|
|
2481
|
+
break;
|
|
2216
2482
|
case "claim.acquire":
|
|
2217
2483
|
case "claim.release":
|
|
2218
2484
|
case "claim.conflict":
|
|
@@ -2493,7 +2759,7 @@ function runHealth(opts: { since: string; json?: boolean }): void {
|
|
|
2493
2759
|
activeBySchema[schemaKey] = (activeBySchema[schemaKey] ?? 0) + 1;
|
|
2494
2760
|
const lastHbMs = hb.last_heartbeat ? Date.parse(hb.last_heartbeat) : Number.NaN;
|
|
2495
2761
|
const ageMs = Number.isFinite(lastHbMs) ? nowMs - lastHbMs : Number.POSITIVE_INFINITY;
|
|
2496
|
-
if (ageMs >
|
|
2762
|
+
if (ageMs > freshnessCutoffSecs() * 1000) staleHeartbeats++;
|
|
2497
2763
|
// Zombie heuristics on a parseable heartbeat: no name, or an age so large
|
|
2498
2764
|
// it can only be a broken/epoch timestamp (a real agent would have healed).
|
|
2499
2765
|
if (!hb.name || hb.name === "unknown" || ageMs > ABSURD_AGE_MS) {
|
|
@@ -2533,7 +2799,7 @@ function runHealth(opts: { since: string; json?: boolean }): void {
|
|
|
2533
2799
|
}
|
|
2534
2800
|
if (staleHeartbeats > 0) {
|
|
2535
2801
|
anomalies.push(
|
|
2536
|
-
`${staleHeartbeats} active heartbeat(s) older than ${Math.floor(
|
|
2802
|
+
`${staleHeartbeats} active heartbeat(s) older than ${Math.floor(freshnessCutoffSecs() / 60)}min; heal mechanism may not be firing`,
|
|
2537
2803
|
);
|
|
2538
2804
|
}
|
|
2539
2805
|
const unexpectedSchemas = Object.keys(activeBySchema).filter((k) => k !== "v1");
|
|
@@ -3317,8 +3583,8 @@ function runHeal(opts: {
|
|
|
3317
3583
|
// agent-coord binary at harnery/bin/agent-coord.
|
|
3318
3584
|
const helper = `${root}/harnery/bin/agent-coord`;
|
|
3319
3585
|
const proc = spawnSync(helper, helperArgs, {
|
|
3320
|
-
cwd: root,
|
|
3321
3586
|
encoding: "utf8",
|
|
3587
|
+
...coordHelperOpts(root),
|
|
3322
3588
|
});
|
|
3323
3589
|
|
|
3324
3590
|
if (proc.status !== 0) {
|
package/src/commands/backup.ts
CHANGED
|
@@ -11,31 +11,27 @@
|
|
|
11
11
|
* restore restic restore <id>
|
|
12
12
|
* prune restic forget --keep-daily 7 --keep-weekly 4 --prune
|
|
13
13
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
14
|
+
* Defaults (repo path, password file, and the keep-daily/weekly/monthly prune
|
|
15
|
+
* policy) come from `.harnery/config.jsonc` `backup.*`, overridable per field by
|
|
16
|
+
* env vars (HARNERY_RESTIC_REPO, HARNERY_RESTIC_PASSWORD_FILE) and CLI flags —
|
|
17
|
+
* see `backupConfig()`. The wrapper deliberately doesn't try to abstract restic;
|
|
18
|
+
* it surfaces it, with opinionated defaults plus passthrough after `--`.
|
|
18
19
|
*/
|
|
19
20
|
|
|
20
21
|
import { spawnSync } from "node:child_process";
|
|
21
22
|
import { randomBytes } from "node:crypto";
|
|
22
23
|
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
23
|
-
import os from "node:os";
|
|
24
24
|
import path from "node:path";
|
|
25
25
|
import type { Command } from "commander";
|
|
26
26
|
import type { EmitContext } from "../commander.ts";
|
|
27
|
+
import { backupConfig } from "../core/config.ts";
|
|
27
28
|
|
|
28
29
|
function defaultRepo(): string {
|
|
29
|
-
|
|
30
|
-
return process.env.HARNERY_RESTIC_REPO ?? path.join(home, ".cache", "harnery", "restic-repo");
|
|
30
|
+
return backupConfig().repo;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
function defaultPasswordFile(): string {
|
|
34
|
-
|
|
35
|
-
return (
|
|
36
|
-
process.env.HARNERY_RESTIC_PASSWORD_FILE ??
|
|
37
|
-
path.join(home, ".config", "harnery", "restic-password")
|
|
38
|
-
);
|
|
34
|
+
return backupConfig().passwordFile;
|
|
39
35
|
}
|
|
40
36
|
|
|
41
37
|
function findHarneryDir(): string | null {
|
|
@@ -237,12 +233,17 @@ export function registerBackupCommand(program: Command, emit: EmitContext): void
|
|
|
237
233
|
});
|
|
238
234
|
|
|
239
235
|
// -- prune --
|
|
236
|
+
// Prune-policy defaults come from backup.keep_* in config.jsonc (falling back
|
|
237
|
+
// to 7/4/6); an explicit --keep-* flag still overrides per invocation.
|
|
238
|
+
const prunePolicy = backupConfig();
|
|
240
239
|
backup
|
|
241
240
|
.command("prune")
|
|
242
|
-
.description(
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
.option("--keep-
|
|
241
|
+
.description(
|
|
242
|
+
`Forget + prune snapshots (default policy: keep ${prunePolicy.keepDaily} daily / ${prunePolicy.keepWeekly} weekly / ${prunePolicy.keepMonthly} monthly).`,
|
|
243
|
+
)
|
|
244
|
+
.option("--keep-daily <n>", "Daily snapshots to keep", String(prunePolicy.keepDaily))
|
|
245
|
+
.option("--keep-weekly <n>", "Weekly snapshots to keep", String(prunePolicy.keepWeekly))
|
|
246
|
+
.option("--keep-monthly <n>", "Monthly snapshots to keep", String(prunePolicy.keepMonthly))
|
|
246
247
|
.option("--dry-run", "Show what would be pruned without actually pruning")
|
|
247
248
|
.option("--repo <path>", "Repository path")
|
|
248
249
|
.option("--password-file <path>", "Password file")
|
package/src/commands/browse.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
type RuntsResult,
|
|
14
14
|
type VisibilityResult,
|
|
15
15
|
type WidthResult,
|
|
16
|
+
wslHeadedLaunchArgs,
|
|
16
17
|
} from "../lib/browser/index.ts";
|
|
17
18
|
import {
|
|
18
19
|
type DiffResult,
|
|
@@ -66,6 +67,7 @@ interface BrowseOpts {
|
|
|
66
67
|
networkHar?: string;
|
|
67
68
|
login?: boolean;
|
|
68
69
|
headed?: boolean;
|
|
70
|
+
browserArg?: string[];
|
|
69
71
|
cookies?: boolean;
|
|
70
72
|
store?: string;
|
|
71
73
|
profile?: string;
|
|
@@ -162,6 +164,16 @@ export function registerBrowseCommand(
|
|
|
162
164
|
)
|
|
163
165
|
.option("--login", "Headed mode for one-time auth flow (cookies persist in profile)")
|
|
164
166
|
.option("--headed", "Headed mode for one-off (no auth-flow framing)")
|
|
167
|
+
.option(
|
|
168
|
+
"--browser-arg <flag>",
|
|
169
|
+
"Extra Chromium launch flag, passed straight to the browser (repeatable). " +
|
|
170
|
+
"e.g. --browser-arg --disable-gpu. Also settable machine-wide via the " +
|
|
171
|
+
"HARNERY_BROWSER_ARGS env var (whitespace-separated). Under WSL, headed " +
|
|
172
|
+
"launches auto-add --disable-gpu to fix blank-window paint; opt out with " +
|
|
173
|
+
"HARNERY_BROWSER_NO_WSL_DEFAULTS=1.",
|
|
174
|
+
(value: string, prev: string[] = []) => [...prev, value],
|
|
175
|
+
[] as string[],
|
|
176
|
+
)
|
|
165
177
|
.option("--no-cookies", "Skip cookie-jar attach and persist")
|
|
166
178
|
.option("--store <path>", `Cookie store path (default ${DEFAULT_STORE})`)
|
|
167
179
|
.option("--profile <dir>", `Persistent Chromium profile dir (default ${DEFAULT_PROFILE})`)
|
|
@@ -313,6 +325,7 @@ async function runBrowse(
|
|
|
313
325
|
waitUntil: opts.waitUntil as BrowseOpts["waitUntil"] as never,
|
|
314
326
|
recordHarPath: opts.networkHar ? resolve(opts.networkHar) : undefined,
|
|
315
327
|
extraHeaders: context?.extraHeaders,
|
|
328
|
+
launchArgs: resolveLaunchArgs(opts, Boolean(headed)),
|
|
316
329
|
});
|
|
317
330
|
|
|
318
331
|
// Print mode: --snapshot / --html / --json all suppress file writes.
|
|
@@ -878,6 +891,25 @@ function summarizeDiagnostics(diag: Diagnostics): Record<string, unknown> {
|
|
|
878
891
|
};
|
|
879
892
|
}
|
|
880
893
|
|
|
894
|
+
/**
|
|
895
|
+
* Merge Chromium launch flags from three sources, in order, de-duped:
|
|
896
|
+
* 1. WSLg headed default (`--disable-gpu`) — auto-applied for headed launches
|
|
897
|
+
* under WSL so the window actually paints. Opt out with
|
|
898
|
+
* HARNERY_BROWSER_NO_WSL_DEFAULTS=1.
|
|
899
|
+
* 2. HARNERY_BROWSER_ARGS env (whitespace-separated) — a machine-wide default.
|
|
900
|
+
* 3. --browser-arg flags on this invocation (repeatable).
|
|
901
|
+
*/
|
|
902
|
+
function resolveLaunchArgs(opts: BrowseOpts, headed: boolean): string[] {
|
|
903
|
+
const args: string[] = [];
|
|
904
|
+
if (headed && !process.env.HARNERY_BROWSER_NO_WSL_DEFAULTS) {
|
|
905
|
+
args.push(...wslHeadedLaunchArgs());
|
|
906
|
+
}
|
|
907
|
+
const envArgs = process.env.HARNERY_BROWSER_ARGS?.trim();
|
|
908
|
+
if (envArgs) args.push(...envArgs.split(/\s+/));
|
|
909
|
+
if (opts.browserArg && opts.browserArg.length > 0) args.push(...opts.browserArg);
|
|
910
|
+
return [...new Set(args)];
|
|
911
|
+
}
|
|
912
|
+
|
|
881
913
|
function parseViewport(spec: string): { width: number; height: number } {
|
|
882
914
|
const preset = VIEWPORT_PRESETS[spec.toLowerCase()];
|
|
883
915
|
if (preset) return preset;
|