harnery 0.44.0 → 0.45.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.
Files changed (87) hide show
  1. package/dist/commander.js +1 -0
  2. package/dist/commands/files.d.ts +2 -2
  3. package/dist/commands/files.d.ts.map +1 -1
  4. package/dist/commands/files.js +7 -4
  5. package/dist/commands/servers.d.ts.map +1 -1
  6. package/dist/commands/servers.js +11 -17
  7. package/dist/commands/tunnel.d.ts +15 -0
  8. package/dist/commands/tunnel.d.ts.map +1 -1
  9. package/dist/commands/tunnel.js +453 -153
  10. package/dist/commands/turn-rollback.d.ts +5 -0
  11. package/dist/commands/turn-rollback.d.ts.map +1 -0
  12. package/dist/commands/turn-rollback.js +76 -0
  13. package/dist/commands/workflow.d.ts.map +1 -1
  14. package/dist/commands/workflow.js +44 -0
  15. package/dist/core/agents/coord-client.d.ts.map +1 -1
  16. package/dist/core/agents/coord-client.js +18 -0
  17. package/dist/core/agents/state/proc-start.d.ts +5 -6
  18. package/dist/core/agents/state/proc-start.d.ts.map +1 -1
  19. package/dist/core/agents/state/proc-start.js +13 -9
  20. package/dist/core/agents/state/process-handle.d.ts +26 -0
  21. package/dist/core/agents/state/process-handle.d.ts.map +1 -0
  22. package/dist/core/agents/state/process-handle.js +121 -0
  23. package/dist/core/artifacts/delivery-card.d.ts.map +1 -1
  24. package/dist/core/artifacts/delivery-card.js +2 -1
  25. package/dist/core/resources/service.d.ts +2 -16
  26. package/dist/core/resources/service.d.ts.map +1 -1
  27. package/dist/core/resources/service.js +1 -78
  28. package/dist/core/semantic/service.d.ts.map +1 -1
  29. package/dist/core/semantic/service.js +2 -9
  30. package/dist/core/servers/index.d.ts +12 -11
  31. package/dist/core/servers/index.d.ts.map +1 -1
  32. package/dist/core/servers/index.js +194 -81
  33. package/dist/core/servers/tunnels.d.ts +5 -3
  34. package/dist/core/servers/tunnels.d.ts.map +1 -1
  35. package/dist/core/servers/tunnels.js +45 -2
  36. package/dist/core/supervisor/service.d.ts +2 -4
  37. package/dist/core/supervisor/service.d.ts.map +1 -1
  38. package/dist/core/turn-rollback/content.d.ts +53 -0
  39. package/dist/core/turn-rollback/content.d.ts.map +1 -0
  40. package/dist/core/turn-rollback/content.js +396 -0
  41. package/dist/core/turn-rollback/index.d.ts +72 -0
  42. package/dist/core/turn-rollback/index.d.ts.map +1 -0
  43. package/dist/core/turn-rollback/index.js +443 -0
  44. package/dist/core/workflow/index.d.ts +4 -2
  45. package/dist/core/workflow/index.d.ts.map +1 -1
  46. package/dist/core/workflow/index.js +2 -1
  47. package/dist/core/workflow/recovery-audit.d.ts +30 -0
  48. package/dist/core/workflow/recovery-audit.d.ts.map +1 -0
  49. package/dist/core/workflow/recovery-audit.js +116 -0
  50. package/dist/core/workflow/run-state.d.ts +19 -2
  51. package/dist/core/workflow/run-state.d.ts.map +1 -1
  52. package/dist/core/workflow/run-state.js +113 -8
  53. package/dist/core/workflow/workspaces/local-git.d.ts.map +1 -1
  54. package/dist/core/workflow/workspaces/local-git.js +70 -14
  55. package/dist/core/workflow/workspaces/reclaim.d.ts +6 -1
  56. package/dist/core/workflow/workspaces/reclaim.d.ts.map +1 -1
  57. package/dist/core/workflow/workspaces/recovery.d.ts +2 -0
  58. package/dist/core/workflow/workspaces/recovery.d.ts.map +1 -1
  59. package/dist/core/workflow/workspaces/recovery.js +24 -0
  60. package/dist/lib/tunnel/state.d.ts +13 -6
  61. package/dist/lib/tunnel/state.d.ts.map +1 -1
  62. package/dist/lib/tunnel/state.js +58 -12
  63. package/package.json +6 -1
  64. package/src/commander.ts +10 -0
  65. package/src/commands/files.ts +13 -5
  66. package/src/commands/servers.ts +12 -23
  67. package/src/commands/tunnel.ts +533 -166
  68. package/src/commands/turn-rollback.ts +111 -0
  69. package/src/commands/workflow.ts +59 -0
  70. package/src/core/agents/coord-client.ts +20 -0
  71. package/src/core/agents/state/proc-start.ts +14 -11
  72. package/src/core/agents/state/process-handle.ts +165 -0
  73. package/src/core/artifacts/delivery-card.ts +7 -1
  74. package/src/core/resources/service.ts +3 -88
  75. package/src/core/semantic/service.ts +2 -8
  76. package/src/core/servers/index.ts +234 -85
  77. package/src/core/servers/tunnels.ts +55 -3
  78. package/src/core/supervisor/service.ts +2 -3
  79. package/src/core/turn-rollback/content.ts +499 -0
  80. package/src/core/turn-rollback/index.ts +580 -0
  81. package/src/core/workflow/index.ts +12 -1
  82. package/src/core/workflow/recovery-audit.ts +181 -0
  83. package/src/core/workflow/run-state.ts +137 -8
  84. package/src/core/workflow/workspaces/local-git.ts +86 -15
  85. package/src/core/workflow/workspaces/reclaim.ts +4 -0
  86. package/src/core/workflow/workspaces/recovery.ts +23 -0
  87. package/src/lib/tunnel/state.ts +70 -15
@@ -1,7 +1,8 @@
1
1
  // Tunnel config + state persistence + provider helpers. Commands default to
2
2
  // <cwd>/.cache/tunnel/; gitignored, so the allowlist is per-machine.
3
3
  import { spawnSync } from "node:child_process";
4
- import { accessSync, chmodSync, constants, existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
4
+ import { randomUUID } from "node:crypto";
5
+ import { accessSync, chmodSync, constants, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
5
6
  import { homedir } from "node:os";
6
7
  import { delimiter, join, resolve } from "node:path";
7
8
  import { isPathAllowed } from "./path-scope.js";
@@ -104,7 +105,17 @@ export function readState(name = DEFAULT_INSTANCE, root = process.cwd()) {
104
105
  }
105
106
  }
106
107
  export function writeState(state, root = process.cwd()) {
107
- writeFileSync(cachePath(stateFile(state.name), root), JSON.stringify(state, null, 2));
108
+ const path = cachePath(stateFile(state.name), root);
109
+ const temporary = `${path}.${randomUUID()}.tmp`;
110
+ try {
111
+ writeFileSync(temporary, JSON.stringify(state, null, 2), { flag: "wx", mode: 0o600 });
112
+ renameSync(temporary, path);
113
+ }
114
+ catch (error) {
115
+ if (existsSync(temporary))
116
+ unlinkSync(temporary);
117
+ throw error;
118
+ }
108
119
  }
109
120
  export function clearState(name = DEFAULT_INSTANCE, root = process.cwd()) {
110
121
  const p = cachePath(stateFile(name), root);
@@ -139,14 +150,49 @@ export function isProcessAlive(pid) {
139
150
  return false;
140
151
  }
141
152
  }
142
- /** True when both the local gate and its provider-side process are live. */
143
- export function isTunnelStateLive(state, processAlive = isProcessAlive) {
144
- if (!processAlive(state.gate_pid))
145
- return false;
146
- if (state.provider === "tailscale")
147
- return true;
148
- const providerPid = state.cloudflared_pid ?? state.provider_pid;
149
- return typeof providerPid === "number" && processAlive(providerPid);
153
+ /** Classify saved original witnesses under the verifier's registration custody. */
154
+ export function tunnelReadiness(state, verifier) {
155
+ if (!state ||
156
+ typeof state !== "object" ||
157
+ Object.hasOwn(state, "provider_pid") ||
158
+ Object.hasOwn(state, "provider_start_token") ||
159
+ typeof state.registration_id !== "string" ||
160
+ !state.registration_id ||
161
+ !Number.isSafeInteger(state.gate_pid) ||
162
+ state.gate_pid < 1 ||
163
+ typeof state.gate_start_token !== "string" ||
164
+ !state.gate_start_token)
165
+ return "unknown";
166
+ const targets = [{ pid: state.gate_pid, token: state.gate_start_token }];
167
+ if (state.provider === "tailscale") {
168
+ if (state.provider_state !== "ready" ||
169
+ state.cloudflared_pid !== undefined ||
170
+ state.cloudflared_start_token !== undefined)
171
+ return "unknown";
172
+ }
173
+ else if (state.provider === "cloudflare") {
174
+ if (!Number.isSafeInteger(state.cloudflared_pid) ||
175
+ state.cloudflared_pid < 1 ||
176
+ typeof state.cloudflared_start_token !== "string" ||
177
+ !state.cloudflared_start_token)
178
+ return "unknown";
179
+ targets.push({ pid: state.cloudflared_pid, token: state.cloudflared_start_token });
180
+ }
181
+ else
182
+ return "unknown";
183
+ try {
184
+ return verifier.withRegistration(state, () => {
185
+ const observed = targets.map(({ pid, token }) => verifier.observeProcess(pid, token));
186
+ if (observed.every((value) => value === "absent"))
187
+ return "dead";
188
+ if (observed.every((value) => value === "same"))
189
+ return "ready";
190
+ return "unknown";
191
+ });
192
+ }
193
+ catch {
194
+ return "unknown";
195
+ }
150
196
  }
151
197
  export function tunnelTargetPort(state) {
152
198
  const value = /^[a-z][a-z\d+.-]*:\/\//i.test(state.target)
@@ -173,10 +219,10 @@ export function tunnelTargetPort(state) {
173
219
  * link 400s. Matching the vhost too keeps the returned tunnel the one that
174
220
  * actually serves the caller's origin.
175
221
  */
176
- export function findLiveTunnelForOrigin(port, vhost, root = process.cwd(), processAlive = isProcessAlive) {
222
+ export function findLiveTunnelForOrigin(port, vhost, root, verifier) {
177
223
  return (listStates(root).find((state) => tunnelTargetPort(state) === port &&
178
224
  state.vhost === vhost &&
179
- isTunnelStateLive(state, processAlive)) ?? null);
225
+ tunnelReadiness(state, verifier) === "ready") ?? null);
180
226
  }
181
227
  /** True when the tunnel's path scope forwards every one of `paths`. */
182
228
  export function tunnelServesPaths(state, paths) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "harnery",
3
- "version": "0.44.0",
3
+ "version": "0.45.0",
4
4
  "description": "Multi-agent coordination for AI coding agents - Claude Code, Cursor, and Codex.",
5
5
  "license": "MIT",
6
6
  "author": "Ryan Kelly",
@@ -67,6 +67,11 @@
67
67
  "types": "./dist/core/context/index.d.ts",
68
68
  "import": "./dist/core/context/index.js"
69
69
  },
70
+ "./core/turn-rollback": {
71
+ "bun": "./src/core/turn-rollback/index.ts",
72
+ "types": "./dist/core/turn-rollback/index.d.ts",
73
+ "import": "./dist/core/turn-rollback/index.js"
74
+ },
70
75
  "./core/workflow": {
71
76
  "bun": "./src/core/workflow/index.ts",
72
77
  "types": "./dist/core/workflow/index.d.ts",
package/src/commander.ts CHANGED
@@ -795,6 +795,16 @@ function harneryCommandBundles({
795
795
  load: async (program) =>
796
796
  (await import("./commands/workflow.ts")).registerWorkflowCommand(program, emit),
797
797
  },
798
+ lazy(
799
+ "turn-rollback",
800
+ "Capture and review exact turn paths before restoring owned content.",
801
+ async (program) =>
802
+ (await import("./commands/turn-rollback.ts")).registerTurnRollbackCommand(
803
+ program,
804
+ emit,
805
+ context,
806
+ ),
807
+ ),
798
808
  lazy("work", "Track durable objectives across bounded workflow attempts.", async (program) =>
799
809
  (await import("./commands/work.ts")).registerWorkCommand(program, emit),
800
810
  ),
@@ -4,6 +4,7 @@ import type { Command } from "commander";
4
4
  import type { EmitContext, HarneryProgramContext } from "../commander.ts";
5
5
  import { resolveCoordRoot } from "../core/agents/coord-client.ts";
6
6
  import { DEFAULT_WEB_PORT, resolveWebPort } from "../core/config.ts";
7
+ import { createTunnelReadinessVerifier } from "../core/servers/index.ts";
7
8
  import { coordRootId, localCoordRootIdUrl } from "../lib/coord-root-id.ts";
8
9
  import {
9
10
  encodedRepoPath,
@@ -14,10 +15,10 @@ import {
14
15
  } from "../lib/local-file-url.ts";
15
16
  import { isPathAllowed } from "../lib/tunnel/path-scope.ts";
16
17
  import {
17
- isTunnelStateLive,
18
18
  listStates,
19
- type ProcessAliveCheck,
19
+ type TunnelReadinessVerifier,
20
20
  type TunnelState,
21
+ tunnelReadiness,
21
22
  tunnelTargetPort,
22
23
  } from "../lib/tunnel/state.ts";
23
24
 
@@ -208,10 +209,10 @@ export function tunnelFileUrl(
208
209
  local: LocalFileUrl,
209
210
  port: number,
210
211
  states: TunnelState[],
211
- processAlive?: ProcessAliveCheck,
212
+ verifier: TunnelReadinessVerifier,
212
213
  ): string {
213
214
  const live = states.filter(
214
- (state) => tunnelTargetPort(state) === port && isTunnelStateLive(state, processAlive),
215
+ (state) => tunnelTargetPort(state) === port && tunnelReadiness(state, verifier) === "ready",
215
216
  );
216
217
  if (local.mode === "raw") {
217
218
  const path = `/${encodedRepoPath(local.relPath)}`;
@@ -309,7 +310,14 @@ export function registerFilesCommand(
309
310
  mode: opts.raw ? "raw" : opts.viewer ? "viewer" : "auto",
310
311
  });
311
312
  if (opts.verify !== false) await verifyDashboardRoot(coordRoot, port);
312
- const url = opts.tunnel ? tunnelFileUrl(result, port, listStates(coordRoot)) : result.url;
313
+ const url = opts.tunnel
314
+ ? tunnelFileUrl(
315
+ result,
316
+ port,
317
+ listStates(coordRoot),
318
+ createTunnelReadinessVerifier({ coordRoot }),
319
+ )
320
+ : result.url;
313
321
  emit.text(`${url}\n`);
314
322
  } catch (error) {
315
323
  if (error instanceof TunnelLinkError) {
@@ -1,5 +1,4 @@
1
1
  import { existsSync, readFileSync, statSync } from "node:fs";
2
- import { hostname } from "node:os";
3
2
  import type { Command } from "commander";
4
3
  import type { EmitContext, HarneryProgramContext } from "../commander.ts";
5
4
  import { resolveCoordRoot } from "../core/agents/coord-client.ts";
@@ -111,23 +110,13 @@ export function registerServersCommand(
111
110
  const preview = Boolean(options.type) && !options.yes;
112
111
  const results: Array<StopResult | { id: string; would_stop: true }> = [];
113
112
  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
- ...(stray.start_token ? { start_token: stray.start_token } : {}),
124
- host: hostname(),
125
- started_at: new Date().toISOString(),
126
- registered_at: new Date().toISOString(),
127
- },
128
- { coordRoot },
129
- ),
130
- );
113
+ const receipt = adoptServer(stray.pid, {
114
+ coordRoot,
115
+ type: "unregistered",
116
+ owner: "none",
117
+ dependencies: { listServers: () => report },
118
+ });
119
+ results.push(await stopServer(receipt, { coordRoot }));
131
120
  }
132
121
  for (const view of selected) {
133
122
  if (preview && !ids.includes(view.record.id))
@@ -408,14 +397,14 @@ export function registerServersCommand(
408
397
 
409
398
  cmd
410
399
  .command("unregister <id>")
411
- .description("Remove a server's record (with --pid, only if the record still names that pid)")
400
+ .description("Remove the exact observed server registration (optionally require --pid)")
412
401
  .option("--pid <pid>", "Only remove when the record names this pid")
413
402
  .action((id: string, options: { pid?: string }) => {
414
403
  try {
415
- const removed = unregisterServer(id, {
416
- coordRoot: root(),
417
- ...(options.pid ? { pid: integer(options.pid, "--pid") } : {}),
418
- });
404
+ const coordRoot = root();
405
+ const record = readServer(id, { coordRoot });
406
+ const matchesPid = !options.pid || record?.pid === integer(options.pid, "--pid");
407
+ const removed = !!record && matchesPid && unregisterServer(record, { coordRoot });
419
408
  emit.config({ format: "json" });
420
409
  emit.data({ id, removed });
421
410
  } catch (error) {