harnery 0.41.0 → 0.42.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/dist/commander.js +3 -0
- package/dist/commands/agents.d.ts.map +1 -1
- package/dist/commands/agents.js +9 -1
- package/dist/commands/artifacts.d.ts.map +1 -1
- package/dist/commands/artifacts.js +113 -12
- package/dist/commands/browse.d.ts +164 -0
- package/dist/commands/browse.d.ts.map +1 -1
- package/dist/commands/browse.js +263 -51
- package/dist/commands/claude-desktop.d.ts.map +1 -1
- package/dist/commands/claude-desktop.js +52 -0
- package/dist/commands/disk.d.ts +6 -0
- package/dist/commands/disk.d.ts.map +1 -0
- package/dist/commands/disk.js +80 -0
- package/dist/commands/doctor.d.ts +8 -0
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +48 -1
- package/dist/commands/files.d.ts +16 -0
- package/dist/commands/files.d.ts.map +1 -1
- package/dist/commands/files.js +54 -2
- package/dist/commands/init.d.ts +2 -2
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +43 -9
- package/dist/commands/rm.d.ts +4 -0
- package/dist/commands/rm.d.ts.map +1 -0
- package/dist/commands/rm.js +87 -0
- package/dist/commands/servers.d.ts +7 -0
- package/dist/commands/servers.d.ts.map +1 -0
- package/dist/commands/servers.js +394 -0
- package/dist/commands/tunnel.d.ts.map +1 -1
- package/dist/commands/tunnel.js +82 -2
- package/dist/core/adapters/profiles.d.ts +9 -0
- package/dist/core/adapters/profiles.d.ts.map +1 -1
- package/dist/core/adapters/profiles.js +22 -1
- package/dist/core/adapters/types.d.ts +9 -0
- package/dist/core/adapters/types.d.ts.map +1 -1
- package/dist/core/agents/cli.js +13 -6
- package/dist/core/agents/render/session-context.d.ts.map +1 -1
- package/dist/core/agents/render/session-context.js +2 -0
- package/dist/core/agents/state/live-coordination-view.d.ts +20 -0
- package/dist/core/agents/state/live-coordination-view.d.ts.map +1 -1
- package/dist/core/agents/state/live-coordination-view.js +60 -0
- package/dist/core/artifacts/delivery-card.js +9 -2
- package/dist/core/artifacts/index.d.ts +140 -4
- package/dist/core/artifacts/index.d.ts.map +1 -1
- package/dist/core/artifacts/index.js +522 -49
- package/dist/core/config.d.ts +68 -11
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +128 -3
- package/dist/core/diagnostics/bundle.d.ts.map +1 -1
- package/dist/core/diagnostics/bundle.js +2 -2
- package/dist/core/events/v3/producers/recorder.d.ts.map +1 -1
- package/dist/core/events/v3/producers/recorder.js +16 -0
- package/dist/core/events/v3/projection.js +9 -1
- package/dist/core/hooks/adapter/events.d.ts +17 -0
- package/dist/core/hooks/adapter/events.d.ts.map +1 -1
- package/dist/core/hooks/adapter/events.js +5 -0
- package/dist/core/hooks/adapter/runtime-telemetry.js +3 -1
- package/dist/core/hooks/adapter/wiring.d.ts +37 -7
- package/dist/core/hooks/adapter/wiring.d.ts.map +1 -1
- package/dist/core/hooks/adapter/wiring.js +62 -4
- package/dist/core/hooks/cli.js +45 -6
- package/dist/core/hooks/run-markers.d.ts +37 -0
- package/dist/core/hooks/run-markers.d.ts.map +1 -0
- package/dist/core/hooks/run-markers.js +109 -0
- package/dist/core/servers/index.d.ts +211 -0
- package/dist/core/servers/index.d.ts.map +1 -0
- package/dist/core/servers/index.js +560 -0
- package/dist/core/servers/net.d.ts +58 -0
- package/dist/core/servers/net.d.ts.map +1 -0
- package/dist/core/servers/net.js +270 -0
- package/dist/core/servers/tunnels.d.ts +14 -0
- package/dist/core/servers/tunnels.d.ts.map +1 -0
- package/dist/core/servers/tunnels.js +33 -0
- package/dist/core/storage/builtins.js +1 -0
- package/dist/core/workflow/engine.d.ts.map +1 -1
- package/dist/core/workflow/engine.js +30 -1
- package/dist/core/workflow/sandbox-projection.d.ts +13 -2
- package/dist/core/workflow/sandbox-projection.d.ts.map +1 -1
- package/dist/core/workflow/sandbox-projection.js +23 -0
- package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
- package/dist/core/workflow/spawn-claude.js +6 -1
- package/dist/core/workflow/spawn-codex.d.ts +2 -0
- package/dist/core/workflow/spawn-codex.d.ts.map +1 -1
- package/dist/core/workflow/spawn-codex.js +20 -1
- package/dist/core/workflow/spawn-cursor.d.ts.map +1 -1
- package/dist/core/workflow/spawn-cursor.js +6 -1
- package/dist/core/workflow/spawn-opencode.d.ts.map +1 -1
- package/dist/core/workflow/spawn-opencode.js +4 -1
- package/dist/core/workflow/types.d.ts +47 -2
- package/dist/core/workflow/types.d.ts.map +1 -1
- package/dist/core/workflow/worker-access.d.ts +92 -0
- package/dist/core/workflow/worker-access.d.ts.map +1 -0
- package/dist/core/workflow/worker-access.js +450 -0
- package/dist/lib/browser/batch-steps.d.ts +18 -0
- package/dist/lib/browser/batch-steps.d.ts.map +1 -0
- package/dist/lib/browser/batch-steps.js +82 -0
- package/dist/lib/browser/collect/collector.d.ts +99 -0
- package/dist/lib/browser/collect/collector.d.ts.map +1 -0
- package/dist/lib/browser/collect/collector.js +202 -0
- package/dist/lib/browser/collect/fields.d.ts +31 -0
- package/dist/lib/browser/collect/fields.d.ts.map +1 -0
- package/dist/lib/browser/collect/fields.js +75 -0
- package/dist/lib/browser/collect/index.d.ts +6 -0
- package/dist/lib/browser/collect/index.d.ts.map +1 -0
- package/dist/lib/browser/collect/index.js +5 -0
- package/dist/lib/browser/collect/page-driver.d.ts +14 -0
- package/dist/lib/browser/collect/page-driver.d.ts.map +1 -0
- package/dist/lib/browser/collect/page-driver.js +306 -0
- package/dist/lib/browser/collect/presets.d.ts +52 -0
- package/dist/lib/browser/collect/presets.d.ts.map +1 -0
- package/dist/lib/browser/collect/presets.js +176 -0
- package/dist/lib/browser/collect/scroll-plan.d.ts +85 -0
- package/dist/lib/browser/collect/scroll-plan.d.ts.map +1 -0
- package/dist/lib/browser/collect/scroll-plan.js +118 -0
- package/dist/lib/browser/index.d.ts +1 -1
- package/dist/lib/browser/index.d.ts.map +1 -1
- package/dist/lib/browser/page-review-judge.d.ts +2 -0
- package/dist/lib/browser/page-review-judge.d.ts.map +1 -1
- package/dist/lib/browser/page-review-judge.js +13 -0
- package/dist/lib/browser/qa-run-contracts.d.ts +10 -0
- package/dist/lib/browser/qa-run-contracts.d.ts.map +1 -1
- package/dist/lib/browser/qa-run.d.ts.map +1 -1
- package/dist/lib/browser/qa-run.js +1 -0
- package/dist/lib/browser/windows-chrome.d.ts +59 -0
- package/dist/lib/browser/windows-chrome.d.ts.map +1 -0
- package/dist/lib/browser/windows-chrome.js +179 -0
- package/dist/lib/claude-desktop-share.d.ts +136 -0
- package/dist/lib/claude-desktop-share.d.ts.map +1 -0
- package/dist/lib/claude-desktop-share.js +394 -0
- package/dist/lib/disk-usage.d.ts +50 -0
- package/dist/lib/disk-usage.d.ts.map +1 -0
- package/dist/lib/disk-usage.js +303 -0
- package/dist/lib/docs-lint.d.ts +6 -0
- package/dist/lib/docs-lint.d.ts.map +1 -1
- package/dist/lib/docs-lint.js +8 -3
- package/dist/lib/guarded-remove.d.ts +34 -0
- package/dist/lib/guarded-remove.d.ts.map +1 -0
- package/dist/lib/guarded-remove.js +310 -0
- package/dist/lib/instructions/templates.js +8 -8
- package/dist/lib/tunnel/error-page.d.ts +1 -1
- package/dist/lib/tunnel/error-page.d.ts.map +1 -1
- package/dist/lib/tunnel/error-page.js +19 -8
- package/dist/lib/tunnel/gate.js +20 -1
- package/dist/lib/tunnel/path-scope.d.ts +22 -0
- package/dist/lib/tunnel/path-scope.d.ts.map +1 -0
- package/dist/lib/tunnel/path-scope.js +62 -0
- package/dist/lib/tunnel/state.d.ts +5 -0
- package/dist/lib/tunnel/state.d.ts.map +1 -1
- package/dist/lib/tunnel/state.js +17 -3
- package/package.json +6 -1
- package/schemas/config.schema.json +91 -0
- package/src/commander.ts +19 -0
- package/src/commands/agents.ts +14 -0
- package/src/commands/artifacts.ts +171 -19
- package/src/commands/browse.ts +400 -57
- package/src/commands/claude-desktop.ts +61 -0
- package/src/commands/disk.ts +114 -0
- package/src/commands/doctor.ts +52 -1
- package/src/commands/files.ts +93 -2
- package/src/commands/init.ts +55 -5
- package/src/commands/rm.ts +115 -0
- package/src/commands/servers.ts +506 -0
- package/src/commands/tunnel.ts +102 -2
- package/src/core/adapters/profiles.ts +22 -1
- package/src/core/adapters/types.ts +10 -0
- package/src/core/agents/cli.ts +14 -6
- package/src/core/agents/render/session-context.ts +1 -0
- package/src/core/agents/state/live-coordination-view.ts +73 -0
- package/src/core/artifacts/delivery-card.ts +10 -2
- package/src/core/artifacts/index.ts +697 -44
- package/src/core/config.ts +183 -4
- package/src/core/diagnostics/bundle.ts +2 -1
- package/src/core/events/v3/producers/recorder.ts +16 -0
- package/src/core/events/v3/projection.ts +11 -1
- package/src/core/hooks/adapter/events.ts +19 -0
- package/src/core/hooks/adapter/runtime-telemetry.ts +3 -1
- package/src/core/hooks/adapter/wiring.ts +116 -8
- package/src/core/hooks/cli.ts +47 -6
- package/src/core/hooks/run-markers.ts +132 -0
- package/src/core/servers/index.ts +712 -0
- package/src/core/servers/net.ts +283 -0
- package/src/core/servers/tunnels.ts +39 -0
- package/src/core/storage/builtins.ts +1 -0
- package/src/core/workflow/engine.ts +38 -1
- package/src/core/workflow/sandbox-projection.ts +38 -2
- package/src/core/workflow/spawn-claude.ts +5 -1
- package/src/core/workflow/spawn-codex.ts +18 -1
- package/src/core/workflow/spawn-cursor.ts +5 -1
- package/src/core/workflow/spawn-opencode.ts +4 -1
- package/src/core/workflow/types.ts +62 -2
- package/src/core/workflow/worker-access.ts +525 -0
- package/src/lib/browser/batch-steps.ts +79 -0
- package/src/lib/browser/collect/collector.ts +306 -0
- package/src/lib/browser/collect/fields.ts +87 -0
- package/src/lib/browser/collect/index.ts +47 -0
- package/src/lib/browser/collect/page-driver.ts +327 -0
- package/src/lib/browser/collect/presets.ts +237 -0
- package/src/lib/browser/collect/scroll-plan.ts +179 -0
- package/src/lib/browser/index.ts +1 -0
- package/src/lib/browser/page-review-judge.ts +16 -0
- package/src/lib/browser/qa-run-contracts.ts +11 -0
- package/src/lib/browser/qa-run.ts +1 -0
- package/src/lib/browser/windows-chrome.ts +227 -0
- package/src/lib/claude-desktop-share.ts +511 -0
- package/src/lib/disk-usage.ts +347 -0
- package/src/lib/docs-lint.ts +8 -3
- package/src/lib/guarded-remove.ts +376 -0
- package/src/lib/instructions/templates.ts +8 -8
- package/src/lib/tunnel/error-page.ts +25 -9
- package/src/lib/tunnel/gate.ts +22 -1
- package/src/lib/tunnel/path-scope.ts +73 -0
- package/src/lib/tunnel/state.ts +20 -3
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { hostname } from "node:os";
|
|
3
|
+
import type { Command } from "commander";
|
|
4
|
+
import type { EmitContext, HarneryProgramContext } from "../commander.ts";
|
|
5
|
+
import { resolveCoordRoot } from "../core/agents/coord-client.ts";
|
|
6
|
+
import { resolveBinName, serverGcConfig } from "../core/config.ts";
|
|
7
|
+
import {
|
|
8
|
+
adoptServer,
|
|
9
|
+
allocateServerPort,
|
|
10
|
+
isValidServerId,
|
|
11
|
+
listServers,
|
|
12
|
+
planServerGc,
|
|
13
|
+
readServer,
|
|
14
|
+
registerServer,
|
|
15
|
+
SERVER_DEFAULT_IDLE_HOURS,
|
|
16
|
+
SERVER_DEFAULT_OWNER_STALE_HOURS,
|
|
17
|
+
type ServerKind,
|
|
18
|
+
type ServersReport,
|
|
19
|
+
type ServerView,
|
|
20
|
+
type StopResult,
|
|
21
|
+
stopServer,
|
|
22
|
+
touchServer,
|
|
23
|
+
unregisterServer,
|
|
24
|
+
} from "../core/servers/index.ts";
|
|
25
|
+
|
|
26
|
+
export function registerServersCommand(
|
|
27
|
+
program: Command,
|
|
28
|
+
emit: EmitContext,
|
|
29
|
+
context?: HarneryProgramContext,
|
|
30
|
+
): void {
|
|
31
|
+
const root = (): string => {
|
|
32
|
+
const resolved = context?.resolveCoordRoot?.() ?? context?.repoRoot ?? resolveCoordRoot();
|
|
33
|
+
if (!resolved) throw new Error("not inside a Harnery project");
|
|
34
|
+
return resolved;
|
|
35
|
+
};
|
|
36
|
+
const fail = (error: unknown, code = "servers_error"): void => {
|
|
37
|
+
emit.error({ code, message: error instanceof Error ? error.message : String(error) });
|
|
38
|
+
emit.setExitCode(1);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const cmd = program
|
|
42
|
+
.command("servers")
|
|
43
|
+
.description(
|
|
44
|
+
"Every local server started in this project: list, stop, clean up idle session servers, read logs",
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
cmd
|
|
48
|
+
.command("list", { isDefault: true })
|
|
49
|
+
.alias("status")
|
|
50
|
+
.description("List registered servers and any unregistered listener running inside the project")
|
|
51
|
+
.option("--no-scan", "Skip the scan for unregistered listeners")
|
|
52
|
+
.option("--json", "Emit the versioned report")
|
|
53
|
+
.action((options: { scan?: boolean; json?: boolean }) => {
|
|
54
|
+
try {
|
|
55
|
+
const report = listServers({ coordRoot: root(), scan: options.scan !== false });
|
|
56
|
+
if (options.json) {
|
|
57
|
+
emit.config({ format: "json" });
|
|
58
|
+
emit.data(report);
|
|
59
|
+
} else emit.text(renderReport(report, resolveBinName(report.coord_root)));
|
|
60
|
+
} catch (error) {
|
|
61
|
+
fail(error);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
cmd
|
|
66
|
+
.command("stop [ids...]")
|
|
67
|
+
.description("Stop named servers now; --type selects several and previews unless --yes")
|
|
68
|
+
.option("--type <type>", "Select every running server of this type")
|
|
69
|
+
.option(
|
|
70
|
+
"--pid <pid>",
|
|
71
|
+
"Stop an unregistered listener found by the list scan (repeatable)",
|
|
72
|
+
(value: string, previous: string[] = []) => [...previous, value],
|
|
73
|
+
)
|
|
74
|
+
.option("--kind <kind>", "With --type, limit to session or service")
|
|
75
|
+
.option("--yes", "Apply a --type selection")
|
|
76
|
+
.option("--json", "Emit JSON")
|
|
77
|
+
.action(
|
|
78
|
+
async (
|
|
79
|
+
ids: string[],
|
|
80
|
+
options: { type?: string; kind?: string; pid?: string[]; yes?: boolean; json?: boolean },
|
|
81
|
+
) => {
|
|
82
|
+
try {
|
|
83
|
+
const coordRoot = root();
|
|
84
|
+
const pids = (options.pid ?? []).map((value) => integer(value, "--pid"));
|
|
85
|
+
if (!ids.length && !options.type && !pids.length)
|
|
86
|
+
throw new Error("name server ids, or pass --type or --pid");
|
|
87
|
+
const report = listServers({ coordRoot, scan: pids.length > 0 });
|
|
88
|
+
const strays = pids.map((pid) => {
|
|
89
|
+
const found = report.unregistered.find((item) => item.pid === pid);
|
|
90
|
+
if (!found)
|
|
91
|
+
throw new Error(
|
|
92
|
+
`pid ${pid} is not an unregistered listener inside this project; only pids shown by "servers list" can be stopped this way`,
|
|
93
|
+
);
|
|
94
|
+
return found;
|
|
95
|
+
});
|
|
96
|
+
const byId = new Map(report.servers.map((view) => [view.record.id, view]));
|
|
97
|
+
const missing = ids.filter((id) => !byId.has(id));
|
|
98
|
+
if (missing.length) throw new Error(`no running server named: ${missing.join(", ")}`);
|
|
99
|
+
const selected = [
|
|
100
|
+
...ids.map((id) => byId.get(id)!),
|
|
101
|
+
...(options.type
|
|
102
|
+
? report.servers.filter(
|
|
103
|
+
(view) =>
|
|
104
|
+
view.record.type === options.type &&
|
|
105
|
+
view.state === "running" &&
|
|
106
|
+
(!options.kind || view.record.kind === options.kind) &&
|
|
107
|
+
!ids.includes(view.record.id),
|
|
108
|
+
)
|
|
109
|
+
: []),
|
|
110
|
+
];
|
|
111
|
+
const preview = Boolean(options.type) && !options.yes;
|
|
112
|
+
const results: Array<StopResult | { id: string; would_stop: true }> = [];
|
|
113
|
+
for (const stray of strays) {
|
|
114
|
+
results.push(
|
|
115
|
+
await stopServer(
|
|
116
|
+
{
|
|
117
|
+
schema_version: 1,
|
|
118
|
+
id: `pid-${stray.pid}`,
|
|
119
|
+
kind: "session",
|
|
120
|
+
type: "unregistered",
|
|
121
|
+
label: stray.command,
|
|
122
|
+
pid: stray.pid,
|
|
123
|
+
host: hostname(),
|
|
124
|
+
started_at: new Date().toISOString(),
|
|
125
|
+
registered_at: new Date().toISOString(),
|
|
126
|
+
},
|
|
127
|
+
{ coordRoot },
|
|
128
|
+
),
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
for (const view of selected) {
|
|
132
|
+
if (preview && !ids.includes(view.record.id))
|
|
133
|
+
results.push({ id: view.record.id, would_stop: true });
|
|
134
|
+
else results.push(await stopServer(view.record, { coordRoot }));
|
|
135
|
+
}
|
|
136
|
+
if (options.json) {
|
|
137
|
+
emit.config({ format: "json" });
|
|
138
|
+
emit.data({ applied: !preview, results });
|
|
139
|
+
} else {
|
|
140
|
+
emit.text(
|
|
141
|
+
results.length
|
|
142
|
+
? results
|
|
143
|
+
.map((r) =>
|
|
144
|
+
"would_stop" in r
|
|
145
|
+
? `would stop ${r.id}`
|
|
146
|
+
: `${r.stopped ? "stopped" : r.method === "not-running" ? "already stopped" : "could not stop"} ${r.id}${r.detail ? ` (${r.detail})` : ""}`,
|
|
147
|
+
)
|
|
148
|
+
.join("\n") +
|
|
149
|
+
(preview ? `\nPreview only. Re-run with --yes to stop these.` : "")
|
|
150
|
+
: "No matching running servers.",
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
if (results.some((r) => "stopped" in r && !r.stopped && r.method === "refused"))
|
|
154
|
+
emit.setExitCode(1);
|
|
155
|
+
} catch (error) {
|
|
156
|
+
fail(error);
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
cmd
|
|
162
|
+
.command("gc")
|
|
163
|
+
.description(
|
|
164
|
+
`Stop idle session servers whose starting agent ended (preview unless --yes); services are never touched`,
|
|
165
|
+
)
|
|
166
|
+
.option(
|
|
167
|
+
"--idle-hours <hours>",
|
|
168
|
+
`Idle window before a server may stop (default ${SERVER_DEFAULT_IDLE_HOURS})`,
|
|
169
|
+
)
|
|
170
|
+
.option(
|
|
171
|
+
"--owner-stale-hours <hours>",
|
|
172
|
+
`Treat a starting agent unobserved this long as gone (default ${SERVER_DEFAULT_OWNER_STALE_HOURS})`,
|
|
173
|
+
)
|
|
174
|
+
.option("--yes", "Stop the selected servers")
|
|
175
|
+
.option("--json", "Emit JSON")
|
|
176
|
+
.action(
|
|
177
|
+
async (options: {
|
|
178
|
+
idleHours?: string;
|
|
179
|
+
ownerStaleHours?: string;
|
|
180
|
+
yes?: boolean;
|
|
181
|
+
json?: boolean;
|
|
182
|
+
}) => {
|
|
183
|
+
try {
|
|
184
|
+
const coordRoot = root();
|
|
185
|
+
const configured = serverGcConfig(coordRoot);
|
|
186
|
+
const idleHours = positive(
|
|
187
|
+
options.idleHours,
|
|
188
|
+
configured.idle_hours ?? SERVER_DEFAULT_IDLE_HOURS,
|
|
189
|
+
"--idle-hours",
|
|
190
|
+
);
|
|
191
|
+
const ownerStaleHours = positive(
|
|
192
|
+
options.ownerStaleHours,
|
|
193
|
+
configured.owner_stale_hours ?? SERVER_DEFAULT_OWNER_STALE_HOURS,
|
|
194
|
+
"--owner-stale-hours",
|
|
195
|
+
);
|
|
196
|
+
const report = listServers({ coordRoot, scan: false, ownerStaleHours });
|
|
197
|
+
const decisions = planServerGc(report.servers, { idleHours, ownerStaleHours });
|
|
198
|
+
const stops = decisions.filter((d) => d.action === "stop");
|
|
199
|
+
const results: StopResult[] = [];
|
|
200
|
+
if (options.yes) {
|
|
201
|
+
for (const decision of stops) {
|
|
202
|
+
const view = report.servers.find((v) => v.record.id === decision.id)!;
|
|
203
|
+
results.push(await stopServer(view.record, { coordRoot }));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (options.json) {
|
|
207
|
+
emit.config({ format: "json" });
|
|
208
|
+
emit.data({ applied: Boolean(options.yes), pruned: report.pruned, decisions, results });
|
|
209
|
+
} else {
|
|
210
|
+
const lines = decisions.map(
|
|
211
|
+
(d) =>
|
|
212
|
+
`${d.action === "stop" ? (options.yes ? "stopped" : "would stop") : "keep"} ${d.id} ${d.reason}`,
|
|
213
|
+
);
|
|
214
|
+
if (report.pruned.length)
|
|
215
|
+
lines.push(`Removed records of exited servers: ${report.pruned.join(", ")}`);
|
|
216
|
+
if (!decisions.length) lines.push("No registered servers.");
|
|
217
|
+
if (stops.length && !options.yes)
|
|
218
|
+
lines.push("Preview only. Re-run with --yes to stop.");
|
|
219
|
+
emit.text(lines.join("\n"));
|
|
220
|
+
}
|
|
221
|
+
} catch (error) {
|
|
222
|
+
fail(error);
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
cmd
|
|
228
|
+
.command("adopt")
|
|
229
|
+
.description(
|
|
230
|
+
"Register running servers that never registered (only pids the list reports as unregistered)",
|
|
231
|
+
)
|
|
232
|
+
.option(
|
|
233
|
+
"--pid <pid>",
|
|
234
|
+
"Adopt this unregistered listener (repeatable)",
|
|
235
|
+
(value: string, previous: string[] = []) => [...previous, value],
|
|
236
|
+
)
|
|
237
|
+
.option("--all", "Adopt every unregistered listener the scan reports")
|
|
238
|
+
.option("--type <type>", "Server family to record", "adopted")
|
|
239
|
+
.option("--kind <kind>", "session (cleaned up when idle) or service", "session")
|
|
240
|
+
.option("--label <label>", "Human-readable name (single --pid only)")
|
|
241
|
+
.option("--scope <path>", "What the server serves (default: its working directory)")
|
|
242
|
+
.option("--no-owner", "Record no owner, so gc never stops it")
|
|
243
|
+
.option("--json", "Emit JSON")
|
|
244
|
+
.action(
|
|
245
|
+
(options: {
|
|
246
|
+
pid?: string[];
|
|
247
|
+
all?: boolean;
|
|
248
|
+
type: string;
|
|
249
|
+
kind: string;
|
|
250
|
+
label?: string;
|
|
251
|
+
scope?: string;
|
|
252
|
+
owner?: boolean;
|
|
253
|
+
json?: boolean;
|
|
254
|
+
}) => {
|
|
255
|
+
try {
|
|
256
|
+
const coordRoot = root();
|
|
257
|
+
if (options.kind !== "session" && options.kind !== "service")
|
|
258
|
+
throw new Error("--kind must be session or service");
|
|
259
|
+
const pids = options.all
|
|
260
|
+
? [...new Set(listServers({ coordRoot, sample: false }).unregistered.map((u) => u.pid))]
|
|
261
|
+
: (options.pid ?? []).map((value) => integer(value, "--pid"));
|
|
262
|
+
if (!pids.length)
|
|
263
|
+
throw new Error(options.all ? "no unregistered listeners" : "pass --pid or --all");
|
|
264
|
+
if (options.label && pids.length > 1) throw new Error("--label needs a single --pid");
|
|
265
|
+
const records = pids.map((pid) =>
|
|
266
|
+
adoptServer(pid, {
|
|
267
|
+
coordRoot,
|
|
268
|
+
type: options.type,
|
|
269
|
+
kind: options.kind as ServerKind,
|
|
270
|
+
...(options.label ? { label: options.label } : {}),
|
|
271
|
+
...(options.scope ? { scope: options.scope } : {}),
|
|
272
|
+
owner: options.owner === false ? "none" : "self",
|
|
273
|
+
}),
|
|
274
|
+
);
|
|
275
|
+
if (options.json) {
|
|
276
|
+
emit.config({ format: "json" });
|
|
277
|
+
emit.data({ adopted: records });
|
|
278
|
+
} else emit.text(records.map((r) => `adopted ${r.id} ${r.url ?? ""}`).join("\n"));
|
|
279
|
+
} catch (error) {
|
|
280
|
+
fail(error);
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
cmd
|
|
286
|
+
.command("port <type>")
|
|
287
|
+
.description(
|
|
288
|
+
"Print a free port from the type's configured range (.harnery/config.jsonc servers.port_ranges)",
|
|
289
|
+
)
|
|
290
|
+
.action((type: string) => {
|
|
291
|
+
try {
|
|
292
|
+
const port = allocateServerPort(type, { coordRoot: root() });
|
|
293
|
+
if (port === null) throw new Error(`no port range configured for ${type}`);
|
|
294
|
+
emit.text(String(port));
|
|
295
|
+
} catch (error) {
|
|
296
|
+
fail(error);
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
cmd
|
|
301
|
+
.command("logs <id>")
|
|
302
|
+
.description("Print the end of a server's log file")
|
|
303
|
+
.option("-n, --lines <n>", "Lines to print", "60")
|
|
304
|
+
.action((id: string, options: { lines: string }) => {
|
|
305
|
+
try {
|
|
306
|
+
const record = readServer(id, { coordRoot: root() });
|
|
307
|
+
if (!record) throw new Error(`no server named ${id}`);
|
|
308
|
+
if (!record.log || !existsSync(record.log))
|
|
309
|
+
throw new Error(`${id} did not record a log file`);
|
|
310
|
+
const lines = positive(options.lines, 60, "--lines");
|
|
311
|
+
const size = statSync(record.log).size;
|
|
312
|
+
const content = readFileSync(record.log, "utf8").slice(Math.max(0, size - 512 * 1024));
|
|
313
|
+
emit.text(
|
|
314
|
+
content
|
|
315
|
+
.split("\n")
|
|
316
|
+
.slice(-Math.floor(lines) - 1)
|
|
317
|
+
.join("\n"),
|
|
318
|
+
);
|
|
319
|
+
} catch (error) {
|
|
320
|
+
fail(error);
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
cmd
|
|
325
|
+
.command("open <id>")
|
|
326
|
+
.description("Print a server's URL")
|
|
327
|
+
.action((id: string) => {
|
|
328
|
+
try {
|
|
329
|
+
const record = readServer(id, { coordRoot: root() });
|
|
330
|
+
if (!record) throw new Error(`no server named ${id}`);
|
|
331
|
+
if (!record.url) throw new Error(`${id} did not record a URL`);
|
|
332
|
+
emit.text(record.url);
|
|
333
|
+
} catch (error) {
|
|
334
|
+
fail(error);
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
cmd
|
|
339
|
+
.command("register")
|
|
340
|
+
.description(
|
|
341
|
+
"Record a server started by a script or another language (prints the record; the calling agent is recorded as owner)",
|
|
342
|
+
)
|
|
343
|
+
.requiredOption("--type <type>", "Server family, for example preview or review")
|
|
344
|
+
.option("--kind <kind>", "session (per piece of work) or service (long-running)", "session")
|
|
345
|
+
.option(
|
|
346
|
+
"--pid <pid>",
|
|
347
|
+
"Process that owns the server's lifetime (default: this command's parent)",
|
|
348
|
+
)
|
|
349
|
+
.option("--port <port>", "Listening port")
|
|
350
|
+
.option("--id <id>", "Stable id (default derived from type and scope)")
|
|
351
|
+
.option("--url <url>", "Local URL")
|
|
352
|
+
.option("--scope <path>", "What the server serves, usually a directory")
|
|
353
|
+
.option("--label <label>", "Human-readable name")
|
|
354
|
+
.option("--log <path>", "Log file")
|
|
355
|
+
.option("--cwd <path>", "Working directory")
|
|
356
|
+
.action(
|
|
357
|
+
(options: {
|
|
358
|
+
type: string;
|
|
359
|
+
kind: string;
|
|
360
|
+
pid?: string;
|
|
361
|
+
port?: string;
|
|
362
|
+
id?: string;
|
|
363
|
+
url?: string;
|
|
364
|
+
scope?: string;
|
|
365
|
+
label?: string;
|
|
366
|
+
log?: string;
|
|
367
|
+
cwd?: string;
|
|
368
|
+
}) => {
|
|
369
|
+
try {
|
|
370
|
+
if (options.kind !== "session" && options.kind !== "service")
|
|
371
|
+
throw new Error("--kind must be session or service");
|
|
372
|
+
if (options.id && !isValidServerId(options.id))
|
|
373
|
+
throw new Error(`invalid id: ${options.id}`);
|
|
374
|
+
const record = registerServer(
|
|
375
|
+
{
|
|
376
|
+
kind: options.kind as ServerKind,
|
|
377
|
+
type: options.type,
|
|
378
|
+
pid: options.pid ? integer(options.pid, "--pid") : process.ppid,
|
|
379
|
+
...(options.port ? { port: integer(options.port, "--port") } : {}),
|
|
380
|
+
...(options.id ? { id: options.id } : {}),
|
|
381
|
+
...(options.url ? { url: options.url } : {}),
|
|
382
|
+
...(options.scope ? { scope: options.scope } : {}),
|
|
383
|
+
...(options.label ? { label: options.label } : {}),
|
|
384
|
+
...(options.log ? { log: options.log } : {}),
|
|
385
|
+
...(options.cwd ? { cwd: options.cwd } : {}),
|
|
386
|
+
},
|
|
387
|
+
{ coordRoot: root() },
|
|
388
|
+
);
|
|
389
|
+
emit.config({ format: "json" });
|
|
390
|
+
emit.data(record);
|
|
391
|
+
} catch (error) {
|
|
392
|
+
fail(error);
|
|
393
|
+
}
|
|
394
|
+
},
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
cmd
|
|
398
|
+
.command("unregister <id>")
|
|
399
|
+
.description("Remove a server's record (with --pid, only if the record still names that pid)")
|
|
400
|
+
.option("--pid <pid>", "Only remove when the record names this pid")
|
|
401
|
+
.action((id: string, options: { pid?: string }) => {
|
|
402
|
+
try {
|
|
403
|
+
const removed = unregisterServer(id, {
|
|
404
|
+
coordRoot: root(),
|
|
405
|
+
...(options.pid ? { pid: integer(options.pid, "--pid") } : {}),
|
|
406
|
+
});
|
|
407
|
+
emit.config({ format: "json" });
|
|
408
|
+
emit.data({ id, removed });
|
|
409
|
+
} catch (error) {
|
|
410
|
+
fail(error);
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
cmd
|
|
415
|
+
.command("touch <id>")
|
|
416
|
+
.description("Mark a server as used now, postponing idle cleanup")
|
|
417
|
+
.action((id: string) => {
|
|
418
|
+
try {
|
|
419
|
+
touchServer(id, { coordRoot: root() });
|
|
420
|
+
} catch (error) {
|
|
421
|
+
fail(error);
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function integer(value: string, flag: string): number {
|
|
427
|
+
const parsed = Number(value);
|
|
428
|
+
if (!Number.isSafeInteger(parsed) || parsed < 1)
|
|
429
|
+
throw new Error(`${flag} must be a positive integer`);
|
|
430
|
+
return parsed;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function positive(value: string | undefined, fallback: number, flag: string): number {
|
|
434
|
+
if (value === undefined) return fallback;
|
|
435
|
+
const parsed = Number(value);
|
|
436
|
+
if (!Number.isFinite(parsed) || parsed <= 0) throw new Error(`${flag} must be a positive number`);
|
|
437
|
+
return parsed;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function duration(ms: number): string {
|
|
441
|
+
const minutes = Math.round(ms / 60_000);
|
|
442
|
+
if (minutes < 60) return `${minutes}m`;
|
|
443
|
+
const hours = Math.floor(minutes / 60);
|
|
444
|
+
if (hours < 48) return `${hours}h${String(minutes % 60).padStart(2, "0")}m`;
|
|
445
|
+
return `${Math.floor(hours / 24)}d`;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function ownerText(view: ServerView): string {
|
|
449
|
+
if (!view.record.owner) return "no agent";
|
|
450
|
+
const name = view.record.owner.name ?? view.record.owner.instance_id.slice(0, 8);
|
|
451
|
+
return view.owner_state === "live" ? name : `${name} (${view.owner_state})`;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Plain-text table grouped by kind; exported for tests. */
|
|
455
|
+
export function renderReport(report: ServersReport, bin: string): string {
|
|
456
|
+
const lines: string[] = [];
|
|
457
|
+
for (const kind of ["service", "session"] as const) {
|
|
458
|
+
const views = report.servers.filter((view) => view.record.kind === kind);
|
|
459
|
+
lines.push(
|
|
460
|
+
kind === "service"
|
|
461
|
+
? "Services (stopped only by hand)"
|
|
462
|
+
: "Session servers (cleaned up when idle and their agent is gone)",
|
|
463
|
+
);
|
|
464
|
+
if (!views.length) {
|
|
465
|
+
lines.push(" none");
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
for (const view of views) {
|
|
469
|
+
const { record } = view;
|
|
470
|
+
const state = view.state === "running" ? "" : ` [${view.state}]`;
|
|
471
|
+
const started = Date.parse(record.started_at);
|
|
472
|
+
const use =
|
|
473
|
+
kind === "service"
|
|
474
|
+
? `up ${Number.isFinite(started) ? duration(Date.now() - started) : "?"}`
|
|
475
|
+
: view.connections === null
|
|
476
|
+
? `idle ${duration(view.idle_ms)}`
|
|
477
|
+
: view.connections > 0
|
|
478
|
+
? `${view.connections} open connection(s)`
|
|
479
|
+
: `idle ${duration(view.idle_ms)}`;
|
|
480
|
+
lines.push(
|
|
481
|
+
` ${record.id}${state} ${record.url ?? (record.port ? `port ${record.port}` : "")}`,
|
|
482
|
+
` ${record.label} · ${ownerText(view)} · ${use}`,
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
if (report.scan === "unsupported") {
|
|
487
|
+
lines.push("Unregistered listeners: not checked on this platform");
|
|
488
|
+
} else if (report.unregistered.length) {
|
|
489
|
+
const byPid = new Map<number, { ports: number[]; command: string }>();
|
|
490
|
+
for (const item of report.unregistered) {
|
|
491
|
+
const entry = byPid.get(item.pid) ?? { ports: [], command: item.command };
|
|
492
|
+
entry.ports.push(item.port);
|
|
493
|
+
byPid.set(item.pid, entry);
|
|
494
|
+
}
|
|
495
|
+
lines.push(`Unregistered servers inside this project (${byPid.size} processes):`);
|
|
496
|
+
for (const [pid, entry] of byPid)
|
|
497
|
+
lines.push(` pid ${pid} port ${entry.ports.join(", ")} ${entry.command.slice(0, 100)}`);
|
|
498
|
+
lines.push(
|
|
499
|
+
` These started without registering. Stop one with: ${bin} servers stop --pid <pid>`,
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
if (report.pruned.length)
|
|
503
|
+
lines.push(`Removed records of exited servers: ${report.pruned.join(", ")}`);
|
|
504
|
+
lines.push(`Control: ${bin} servers stop <id> · ${bin} servers gc · ${bin} servers logs <id>`);
|
|
505
|
+
return lines.join("\n");
|
|
506
|
+
}
|