@astrosheep/keiyaku 4.5.0 → 4.5.2

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 (59) hide show
  1. package/build/integrations/marketplace/plugins/keiyaku/.claude-plugin/plugin.json +4 -2
  2. package/build/integrations/marketplace/plugins/keiyaku/.codex-plugin/plugin.json +1 -1
  3. package/build/integrations/marketplace/plugins/keiyaku/package.json +9 -3
  4. package/build/integrations/marketplace/plugins/keiyaku/skills/keiyaku-akuma/SKILL.md +16 -5
  5. package/build/integrations/pi/keiyaku.ts +159 -0
  6. package/build/src/akuma/abort.js +9 -9
  7. package/build/src/akuma/akuma-handle.d.ts +4 -2
  8. package/build/src/akuma/akuma-handle.js +14 -2
  9. package/build/src/akuma/akuma-product.d.ts +8 -2
  10. package/build/src/akuma/akuma-product.js +9 -4
  11. package/build/src/akuma/body.d.ts +4 -0
  12. package/build/src/akuma/body.js +21 -14
  13. package/build/src/akuma/heart/index.js +7 -3
  14. package/build/src/akuma/heart/rows.d.ts +1 -1
  15. package/build/src/akuma/heart/rows.js +3 -3
  16. package/build/src/akuma/heart/storage.d.ts +1 -0
  17. package/build/src/akuma/heart/storage.js +28 -6
  18. package/build/src/akuma/identity.d.ts +1 -0
  19. package/build/src/akuma/identity.js +7 -0
  20. package/build/src/akuma/index.d.ts +1 -1
  21. package/build/src/akuma/projection.d.ts +18 -2
  22. package/build/src/akuma/projection.js +11 -1
  23. package/build/src/akuma/providers/claude/index.js +68 -37
  24. package/build/src/akuma/publication.js +3 -0
  25. package/build/src/akuma/request-lifecycle.d.ts +45 -0
  26. package/build/src/akuma/request-lifecycle.js +171 -0
  27. package/build/src/akuma/request-serve.d.ts +9 -32
  28. package/build/src/akuma/request-serve.js +47 -173
  29. package/build/src/akuma/request-wire.js +1 -1
  30. package/build/src/akuma/requests.js +3 -2
  31. package/build/src/cli/commands/akuma-invoke.d.ts +9 -0
  32. package/build/src/cli/commands/akuma-invoke.js +9 -6
  33. package/build/src/cli/commands/akuma.d.ts +1 -0
  34. package/build/src/cli/commands/akuma.js +16 -6
  35. package/build/src/cli/commands/contract.d.ts +3 -2
  36. package/build/src/cli/commands/contract.js +5 -2
  37. package/build/src/cli/commands/status-set.d.ts +4 -0
  38. package/build/src/cli/commands/status-set.js +61 -0
  39. package/build/src/cli/invoke.js +5 -0
  40. package/build/src/cli/parse.js +1 -1
  41. package/build/src/cli/render/akuma-activity.js +11 -0
  42. package/build/src/cli/render/akuma.js +7 -0
  43. package/build/src/cli/render/status-set.d.ts +5 -0
  44. package/build/src/cli/render/status-set.js +14 -0
  45. package/build/src/cli/render/text.js +3 -0
  46. package/build/src/cli/result.d.ts +15 -1
  47. package/build/src/cli/runtime.js +2 -0
  48. package/build/src/cli/square-edge.d.ts +2 -1
  49. package/build/src/cli/square-edge.js +60 -33
  50. package/build/src/contract-worktree.js +18 -5
  51. package/build/src/library/akuma-creation.d.ts +1 -1
  52. package/build/src/library/akuma-creation.js +6 -2
  53. package/build/src/library/fleet.d.ts +12 -1
  54. package/build/src/library/fleet.js +21 -5
  55. package/build/src/library/repo.js +3 -1
  56. package/build/src/runtime/proc/windows-launch.exe +0 -0
  57. package/build/src/world.d.ts +2 -0
  58. package/build/src/world.js +4 -0
  59. package/package.json +8 -3
@@ -1,7 +1,9 @@
1
1
  {
2
2
  "name": "keiyaku",
3
- "version": "0.1.0+codex.20260819155641",
3
+ "version": "0.1.0",
4
4
  "description": "Use the Keiyaku contract, task, and Akuma CLI.",
5
- "author": { "name": "Keiyaku" },
5
+ "author": {
6
+ "name": "Keiyaku"
7
+ },
6
8
  "skills": "./skills/"
7
9
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keiyaku",
3
- "version": "0.1.0+codex.20260819155641",
3
+ "version": "0.1.0+codex.20260826051747",
4
4
  "description": "Use the Keiyaku contract, task, and Akuma CLI.",
5
5
  "author": {
6
6
  "name": "Keiyaku"
@@ -1,8 +1,14 @@
1
1
  {
2
2
  "name": "keiyaku-harness",
3
- "version": "0.1.0+codex.20260819155641",
3
+ "version": "0.1.0",
4
4
  "type": "module",
5
5
  "main": "./opencode.js",
6
- "keywords": ["pi-package"],
7
- "pi": { "skills": ["./skills"] }
6
+ "keywords": [
7
+ "pi-package"
8
+ ],
9
+ "pi": {
10
+ "skills": [
11
+ "./skills"
12
+ ]
13
+ }
8
14
  }
@@ -84,6 +84,7 @@ choose `--interrupt` when the current attempt itself is the problem.
84
84
 
85
85
  ```bash
86
86
  keiyaku history <aku/...|@alias> --last
87
+ keiyaku history <aku/...|@alias> --id <historyId>
87
88
  keiyaku history <aku/...|@alias> [--before <N> | --since <N>] [--limit <N>]
88
89
  ```
89
90
 
@@ -91,9 +92,17 @@ keiyaku history <aku/...|@alias> [--before <N> | --since <N>] [--limit <N>]
91
92
  and says so plainly when no answer exists yet. Snapshot rows elsewhere may
92
93
  clip long text; the terminal answer from `call`/`wait` and the bytes from
93
94
  `--last` are never clipped — when you need the full result, take it from one
94
- of those. Cursor reads page the activity timeline; `--before` and `--since`
95
- are exclusive sequence cursors. `--limit` defaults to 50 and accepts at most
96
- 5000 semantic rows.
95
+ of those. Every completed answered or failed outcome carries one Heart-owned
96
+ public `historyId` shaped as `turn/<positive-safe-integer>` in `status`, `wait`,
97
+ and history output. Use `--id` with that same value to read exactly one retained
98
+ outcome: text writes the complete answer or diagnostic bytes without clipping
99
+ or framing. Provider-native history coordinates remain private. A malformed or
100
+ unknown ID is a typed nonzero refusal. `--id` is mutually exclusive with
101
+ `--last`, `--before`, `--since`, and `--limit`.
102
+
103
+ Cursor reads page the activity timeline; `--before` and `--since` are exclusive
104
+ sequence cursors. `--limit` defaults to 50 and accepts at most 5000 semantic
105
+ rows.
97
106
 
98
107
  ## Stop
99
108
 
@@ -116,5 +125,7 @@ keiyaku fork <aku/...|@alias> --at <historyId> [--alias @name]
116
125
  `fork` starts a child from one exact retained answered-turn coordinate and
117
126
  leaves the source untouched. It is a provider capability, not a guarantee:
118
127
  when the provider cannot fork from that turn, the command refuses rather than
119
- fabricating a fresh start. Take the `historyId` from `history` output; it must
120
- name an answered turn.
128
+ fabricating a fresh start. Pass the same public `historyId` exposed by status,
129
+ wait, or history; Heart privately resolves the provider coordinate. The ID must
130
+ name an answered outcome with a provider fork point. Failed outcomes are not
131
+ forkable.
@@ -0,0 +1,159 @@
1
+ import { existsSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
5
+
6
+ type Section = Readonly<{ kind?: string; value?: Readonly<{ rows?: readonly Record<string, unknown>[] }> }>;
7
+ type Report = Readonly<{
8
+ contracts?: Section;
9
+ tasks?: Section;
10
+ akuma?: Section;
11
+ }>;
12
+
13
+ type ExecResult = Readonly<{ stdout: string; stderr: string; code: number }>;
14
+
15
+ const REFRESH_TIMEOUT_MS = 10_000;
16
+
17
+ function rows(section: Section | undefined): readonly Record<string, unknown>[] {
18
+ return section?.kind === "present" && Array.isArray(section.value?.rows) ? section.value.rows : [];
19
+ }
20
+
21
+ function failed(section: Section | undefined): boolean {
22
+ return section?.kind === "failed";
23
+ }
24
+
25
+ function contractNeedsAttention(row: Record<string, unknown>): boolean {
26
+ const gates = row.gates;
27
+ if (typeof gates === "object" && gates !== null && "reports" in gates && Array.isArray(gates.reports)) {
28
+ for (const report of gates.reports) {
29
+ if (
30
+ typeof report === "object" &&
31
+ report !== null &&
32
+ "current" in report &&
33
+ typeof report.current === "object" &&
34
+ report.current !== null &&
35
+ "kind" in report.current &&
36
+ report.current.kind === "attested" &&
37
+ "verdict" in report.current &&
38
+ report.current.verdict === "unsatisfied"
39
+ )
40
+ return true;
41
+ }
42
+ }
43
+ return row.targetLag !== undefined &&
44
+ typeof row.targetLag === "object" &&
45
+ row.targetLag !== null &&
46
+ "kind" in row.targetLag
47
+ ? row.targetLag.kind === "unknown"
48
+ : false;
49
+ }
50
+
51
+ function taskNeedsAttention(row: Record<string, unknown>): boolean {
52
+ return row.disposition === "blocked";
53
+ }
54
+
55
+ function akumaNeedsAttention(row: Record<string, unknown>): boolean {
56
+ return row.life === "stillborn" || row.life === "hung" || row.life === "untidy";
57
+ }
58
+
59
+ function summary(report: Report): string {
60
+ const contractRows = rows(report.contracts);
61
+ const taskRows = rows(report.tasks);
62
+ const akumaRows = rows(report.akuma);
63
+ const attention =
64
+ Number(failed(report.contracts)) +
65
+ Number(failed(report.tasks)) +
66
+ Number(failed(report.akuma)) +
67
+ contractRows.filter(contractNeedsAttention).length +
68
+ taskRows.filter(taskNeedsAttention).length +
69
+ akumaRows.filter(akumaNeedsAttention).length;
70
+ const parts = [`Keiyaku · ${contractRows.length} contracts`, `${akumaRows.length} fleet`, `${taskRows.length} tasks`];
71
+ if (attention > 0) parts.push(`! ${attention}`);
72
+ return parts.join(" · ");
73
+ }
74
+
75
+ function parseReport(result: ExecResult): Report | undefined {
76
+ if (result.code !== 0 || result.stdout.trim().length === 0) return undefined;
77
+ try {
78
+ const value: unknown = JSON.parse(result.stdout);
79
+ return typeof value === "object" && value !== null ? (value as Report) : undefined;
80
+ } catch {
81
+ return undefined;
82
+ }
83
+ }
84
+
85
+ async function run(pi: ExtensionAPI, args: readonly string[], timeout = REFRESH_TIMEOUT_MS): Promise<ExecResult> {
86
+ const bundledCli = resolve(dirname(fileURLToPath(import.meta.url)), "../../src/cli/index.js");
87
+ if (existsSync(bundledCli)) return await pi.exec(process.execPath, [bundledCli, ...args], { timeout });
88
+ return await pi.exec("keiyaku", [...args], { timeout });
89
+ }
90
+
91
+ function lineForWidth(value: string, width: number): string {
92
+ const clean = value.replaceAll(/[\u0000-\u001f\u007f]/gu, " ");
93
+ return clean.length <= width ? clean : `${clean.slice(0, Math.max(0, width - 1))}…`;
94
+ }
95
+
96
+ function overlay(lines: readonly string[], theme: Theme, done: () => void) {
97
+ return {
98
+ invalidate(): void {},
99
+ handleInput(data: string): void {
100
+ if (data === "\u001b" || data === "q" || data === "Q" || data === "\r") done();
101
+ },
102
+ render(width: number): string[] {
103
+ const inner = Math.max(20, Math.min(100, width - 4));
104
+ const border = theme.fg("border", "+");
105
+ const rule = theme.fg("border", "-".repeat(inner));
106
+ return [
107
+ `${border}${rule}${border}`,
108
+ `${theme.fg("border", "|")}${lineForWidth("Keiyaku", inner).padEnd(inner)}${theme.fg("border", "|")}`,
109
+ `${theme.fg("border", "|")}${" ".repeat(inner)}${theme.fg("border", "|")}`,
110
+ ...lines.map(
111
+ (line) => `${theme.fg("border", "|")}${lineForWidth(line, inner).padEnd(inner)}${theme.fg("border", "|")}`,
112
+ ),
113
+ `${theme.fg("border", "|")}${" ".repeat(inner)}${theme.fg("border", "|")}`,
114
+ `${theme.fg("border", "|")}${lineForWidth("esc/q/enter close", inner).padEnd(inner)}${theme.fg("border", "|")}`,
115
+ `${border}${rule}${border}`,
116
+ ];
117
+ },
118
+ };
119
+ }
120
+
121
+ export default function keiyakuExtension(pi: ExtensionAPI): void {
122
+ let refreshInFlight: Promise<void> | undefined;
123
+
124
+ const refresh = async (ctx: Pick<ExtensionContext, "hasUI" | "ui">): Promise<void> => {
125
+ if (!ctx.hasUI) return;
126
+ refreshInFlight ??= (async () => {
127
+ const result = await run(pi, ["status", "--json"]);
128
+ const report = parseReport(result);
129
+ if (report === undefined) {
130
+ const diagnostic = result.stderr.trim() || "status unavailable";
131
+ ctx.ui.setWidget("keiyaku", [`Keiyaku · ${lineForWidth(diagnostic, 100)}`]);
132
+ } else ctx.ui.setWidget("keiyaku", [summary(report)]);
133
+ })().finally(() => {
134
+ refreshInFlight = undefined;
135
+ });
136
+ await refreshInFlight;
137
+ };
138
+
139
+ pi.on("session_start", async (_event, ctx) => {
140
+ await refresh(ctx);
141
+ });
142
+ pi.on("turn_end", async (_event, ctx) => {
143
+ await refresh(ctx);
144
+ });
145
+ pi.registerCommand("keiyaku", {
146
+ description: "Open the Kanshi world status",
147
+ handler: async (_args, ctx) => {
148
+ if (!ctx.hasUI) return;
149
+ const result = await run(pi, ["status"]);
150
+ const body =
151
+ result.code === 0 ? result.stdout.trimEnd().split("\n") : [result.stderr.trim() || "status unavailable"];
152
+ await ctx.ui.custom<void>((_tui, theme, _keybindings, done) => overlay(body, theme, done), {
153
+ overlay: true,
154
+ overlayOptions: { anchor: "center", width: 100, maxHeight: 40 },
155
+ });
156
+ await refresh(ctx);
157
+ },
158
+ });
159
+ }
@@ -2,10 +2,13 @@ export function abortable(operation, signal, disposeLate) {
2
2
  signal.throwIfAborted();
3
3
  return new Promise((resolve, reject) => {
4
4
  let aborted = false;
5
+ let settled = false;
5
6
  const abort = () => {
6
7
  aborted = true;
7
- if (disposeLate === undefined)
8
+ if (!settled) {
9
+ settled = true;
8
10
  reject(signal.reason);
11
+ }
9
12
  };
10
13
  signal.addEventListener("abort", abort, { once: true });
11
14
  if (signal.aborted)
@@ -13,19 +16,16 @@ export function abortable(operation, signal, disposeLate) {
13
16
  void operation.then(async (value) => {
14
17
  signal.removeEventListener("abort", abort);
15
18
  if (!aborted) {
19
+ settled = true;
16
20
  resolve(value);
17
21
  return;
18
22
  }
19
- try {
20
- await disposeLate?.(value);
21
- }
22
- catch (error) {
23
- reject(error);
24
- return;
25
- }
26
- reject(signal.reason);
23
+ void Promise.resolve(disposeLate?.(value)).catch(() => undefined);
27
24
  }, (error) => {
28
25
  signal.removeEventListener("abort", abort);
26
+ if (settled)
27
+ return;
28
+ settled = true;
29
29
  reject(aborted ? signal.reason : error);
30
30
  });
31
31
  });
@@ -1,7 +1,7 @@
1
1
  import { type TellResult } from "./body.js";
2
2
  import { type KillEvidence } from "./heart/index.js";
3
3
  import { type AkuId } from "./identity.js";
4
- import { type ActivityHistory } from "./projection.js";
4
+ import { type ActivityHistory, type ExactHistory } from "./projection.js";
5
5
  import type { AkumaCallExecution, AkumaStatus, ForkReceipt, InterruptReceipt } from "./akuma.js";
6
6
  import type { WorldRoot } from "../world.js";
7
7
  declare const CALL_EXECUTION: unique symbol;
@@ -11,12 +11,14 @@ export declare class AkumaHandle {
11
11
  readonly [CALL_EXECUTION]?: AkumaCallExecution;
12
12
  constructor(id: AkuId, worldPath: WorldRoot, execution?: AkumaCallExecution);
13
13
  private get paths();
14
+ private exactHistory;
14
15
  status(): Promise<AkumaStatus>;
15
16
  history(input?: Readonly<{
17
+ id?: string;
16
18
  before?: number;
17
19
  since?: number;
18
20
  limit?: number;
19
- }>): Promise<ActivityHistory>;
21
+ }>): Promise<ActivityHistory | ExactHistory>;
20
22
  wait(predicate?: (status: AkumaStatus) => boolean, options?: Readonly<{
21
23
  timeoutMs?: number;
22
24
  }>): Promise<AkumaStatus>;
@@ -1,8 +1,8 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { CONTROL_RESPONSE_MS, wakeRecordedTell } from "./body.js";
3
3
  import { HeldAkumaLeash, activitySlice, readForkPoint, readHeart, readKill, readLastAnsweredTurn, readSoul, recordTell, requestPause, requestStop, } from "./heart/index.js";
4
- import { pathsForAkuId } from "./identity.js";
5
- import { projectTurns, selectHistory } from "./projection.js";
4
+ import { parsePublicHistoryId, pathsForAkuId } from "./identity.js";
5
+ import { projectTurns, selectHistory, selectExactHistory, } from "./projection.js";
6
6
  import { resolveProviderExecution } from "./providers/index.js";
7
7
  import { publishAkuma } from "./publication.js";
8
8
  import { spawnAkumaBody } from "./body.js";
@@ -83,10 +83,22 @@ export class AkumaHandle {
83
83
  get paths() {
84
84
  return pathsForAkuId(this.worldPath, this.id);
85
85
  }
86
+ async exactHistory(input) {
87
+ if (typeof input.id !== "string" || input.id.trim() === "")
88
+ throw new TypeError("Akuma history id must be a nonblank string");
89
+ if (input.before !== undefined || input.since !== undefined || input.limit !== undefined)
90
+ throw new TypeError("Akuma history id cannot be combined with before, since, or limit");
91
+ if (parsePublicHistoryId(input.id) === null)
92
+ throw new TypeError("Akuma history id must match turn/<positive safe integer>");
93
+ const slice = await activitySlice(this.paths);
94
+ return selectExactHistory(projectTurns(slice.rows, { lowestRetained: slice.lowestRetained, highest: slice.highest }).rows, input.id);
95
+ }
86
96
  async status() {
87
97
  return (await bornStatus(this.paths, this.id, { aperture: "monitoring" })).status;
88
98
  }
89
99
  async history(input = {}) {
100
+ if (input.id !== undefined)
101
+ return this.exactHistory(input);
90
102
  if (input.before !== undefined && input.since !== undefined) {
91
103
  throw new TypeError("Akuma history before and since are mutually exclusive");
92
104
  }
@@ -42,11 +42,17 @@ export declare class Akuma {
42
42
  listArchetypes(): Promise<readonly string[]>;
43
43
  call(input: AkumaCallLaunchInput): Promise<AkumaHandle>;
44
44
  beginCall(input: AkumaCallLaunchInput, context: AkumaCallContext): Promise<AkumaBornCall>;
45
- finishCall(born: AkumaBornCall): Promise<AkumaHandle>;
45
+ finishCall(born: AkumaBornCall, completion?: Readonly<{
46
+ participantName?: string;
47
+ contractId?: string;
48
+ }>): Promise<AkumaHandle>;
46
49
  [CALL_WITH_CONTEXT](input: AkumaCallLaunchInput, context: AkumaCallContext): Promise<AkumaHandle>;
47
50
  list(input?: AkumaListInput): Promise<AkumaList>;
48
51
  }
49
52
  export declare function callAkumaWithContext(akuma: Akuma, input: AkumaCallLaunchInput, context: AkumaCallContext): Promise<AkumaHandle>;
50
53
  export declare function beginAkumaCall(akuma: Akuma, input: AkumaCallLaunchInput, context: AkumaCallContext): Promise<AkumaBornCall>;
51
- export declare function finishAkumaCall(akuma: Akuma, born: AkumaBornCall): Promise<AkumaHandle>;
54
+ export declare function finishAkumaCall(akuma: Akuma, born: AkumaBornCall, completion?: Readonly<{
55
+ participantName?: string;
56
+ contractId?: string;
57
+ }>): Promise<AkumaHandle>;
52
58
  export {};
@@ -112,13 +112,18 @@ export class Akuma {
112
112
  },
113
113
  };
114
114
  }
115
- async finishCall(born) {
115
+ async finishCall(born, completion = {}) {
116
116
  if (born.kind === "requested") {
117
117
  return new AkumaHandle(born.id, this.path, { cwd: born.cwd, source: born.execution.source });
118
118
  }
119
119
  const published = await launchAkuma({
120
120
  allocated: born.allocated,
121
- launch: async (allocated) => await spawnAkumaBody({ paths: allocated.paths, seed: born.seed, initialBody: born.initialBody }),
121
+ launch: async (allocated) => await spawnAkumaBody({
122
+ paths: allocated.paths,
123
+ seed: born.seed,
124
+ initialBody: born.initialBody,
125
+ ...(Object.keys(completion).length === 0 ? {} : { completion }),
126
+ }),
122
127
  });
123
128
  return new AkumaHandle(published.id, this.path, {
124
129
  cwd: born.execution.cwd,
@@ -174,6 +179,6 @@ export async function callAkumaWithContext(akuma, input, context) {
174
179
  export async function beginAkumaCall(akuma, input, context) {
175
180
  return await akuma.beginCall(input, context);
176
181
  }
177
- export async function finishAkumaCall(akuma, born) {
178
- return await akuma.finishCall(born);
182
+ export async function finishAkumaCall(akuma, born, completion = {}) {
183
+ return await akuma.finishCall(born, completion);
179
184
  }
@@ -11,6 +11,10 @@ export type BodyLaunch = Readonly<{
11
11
  birthSession?: Omit<SessionFact, "sequence">;
12
12
  initialBody?: string;
13
13
  refuseIfHeld?: boolean;
14
+ completion?: Readonly<{
15
+ participantName?: string;
16
+ contractId?: string;
17
+ }>;
14
18
  }>;
15
19
  export type TellWake = Readonly<{
16
20
  kind: "told";
@@ -1,11 +1,11 @@
1
1
  import { appendFile } from "node:fs/promises";
2
- import { join } from "node:path";
3
2
  import { fileURLToPath } from "node:url";
4
3
  import { abortableDelay } from "./abort.js";
5
4
  import { BodySupervisor } from "./body-supervisor.js";
6
5
  import { driveTurn, turnRecipe } from "./turn-drive.js";
7
6
  import { HeldAkumaLeash, breakBody, endTurn, finishBodyIfIdle, heartExists, isHeartAbsent, readHeart, readNonterminalRequests, watchHeart, } from "./heart/index.js";
8
7
  import { worldRootForAkumaPaths } from "./identity.js";
8
+ import { keiyakuSquarePath } from "../world.js";
9
9
  import { resolveProviderExecution } from "./providers/index.js";
10
10
  import { clearBodyRequestTransport, settleBodyRequests, } from "./request-serve.js";
11
11
  import { spawnDetachedProcess, } from "../runtime/proc/run.js";
@@ -78,29 +78,30 @@ async function persistTurn(paths, turnSequence, result, completedAt) {
78
78
  await endTurn(paths, { turnSequence, outcome: { kind: "failed", diagnostic }, completedAt });
79
79
  return { outcome: "failed", diagnostic };
80
80
  }
81
- const OUTCOME_PREVIEW_LIMIT = 1_000;
82
- function outcomePreview(identity, outcome) {
83
- const text = outcome.outcome === "answered" ? outcome.answer : outcome.diagnostic;
84
- if (text.length <= OUTCOME_PREVIEW_LIMIT)
85
- return text;
86
- return `${text.slice(0, OUTCOME_PREVIEW_LIMIT)}\n\nkeiyaku history ${identity} --last`;
81
+ function completionMessage(identity, completion) {
82
+ const participant = completion?.participantName;
83
+ const contract = completion?.contractId;
84
+ const header = [identity, participant === undefined ? undefined : `(@${participant})`, contract]
85
+ .filter((value) => value !== undefined)
86
+ .join(" ");
87
+ return `${header}\n✓ came back`;
87
88
  }
88
89
  function boundedDiagnostic(error) {
89
90
  const text = diagnostic(error);
90
91
  return text.length <= 500 ? text : `${text.slice(0, 500)}...`;
91
92
  }
92
- async function expressInitialOutcome(launch, outcome) {
93
+ async function expressInitialOutcome(launch) {
93
94
  try {
94
95
  const { Square } = await import("@astrosheep/square");
95
96
  const identity = launch.seed?.id ?? (await readHeart(launch.paths)).soul?.id;
96
97
  if (identity === undefined)
97
98
  return;
98
- const square = await Square.at({ path: join(worldRootForAkumaPaths(launch.paths), ".square", "PUBLIC.square") });
99
+ const square = await Square.at({ path: keiyakuSquarePath(worldRootForAkumaPaths(launch.paths)) });
99
100
  try {
100
101
  const joined = await square.implicitJoin(identity);
101
102
  if (joined.state === "done" || joined.participant === undefined)
102
103
  return;
103
- await joined.participant.express(outcomePreview(identity, outcome));
104
+ await joined.participant.express(completionMessage(identity, launch.completion));
104
105
  }
105
106
  finally {
106
107
  await square.close();
@@ -217,7 +218,7 @@ async function runBodyTurns(input) {
217
218
  }
218
219
  const outcome = await persistTurn(launch.paths, result.turnSequence, result, runtime.now());
219
220
  if (initial !== undefined)
220
- await expressInitialOutcome(launch, outcome);
221
+ await expressInitialOutcome(launch);
221
222
  if (outcome.outcome === "failed") {
222
223
  await breakBody(launch.paths, { sequence: bodySequence, end: "broke-off", at: runtime.now() });
223
224
  return;
@@ -344,8 +345,14 @@ async function awaitWake(paths, tellId, after, child, changed) {
344
345
  if (winner.kind === "exited") {
345
346
  return winner.exit.code === LEASH_HELD_EXIT ? { kind: "held" } : failedChild(winner.exit);
346
347
  }
347
- await child.terminate();
348
- await child.exited;
348
+ try {
349
+ await child.terminate();
350
+ }
351
+ catch (error) {
352
+ /* Termination rejection leaves physical custody unresolved; release it deliberately. */
353
+ child.release();
354
+ return { kind: "failed", diagnostic: diagnostic(error) };
355
+ }
349
356
  return winner.kind === "observer-failed"
350
357
  ? { kind: "failed", diagnostic: diagnostic(winner.error) }
351
358
  : { kind: "failed", diagnostic: "Heart observer closed" };
@@ -355,11 +362,11 @@ async function wakePendingTells(paths, tellId, runtime) {
355
362
  const watching = new AbortController();
356
363
  let changed;
357
364
  try {
365
+ changed = await runtime.observeHeart(paths, watching.signal);
358
366
  const beforeHeart = await readHeart(paths);
359
367
  if (tellId === undefined && beforeHeart.pending.length === 0)
360
368
  return null;
361
369
  const before = beforeHeart.latestBody?.sequence ?? 0;
362
- changed = await runtime.observeHeart(paths, watching.signal);
363
370
  return await awaitWake(paths, tellId, before, await runtime.spawn(paths), changed);
364
371
  }
365
372
  catch (error) {
@@ -1,7 +1,8 @@
1
+ import { parsePublicHistoryId } from "../identity.js";
1
2
  import { answeredTurnFact, endBodyFact, finishBodyFact, insertActivityFact, insertTurnEndFact, insertTurnStartFact, insertPauseControl, insertSessionFact, insertStopControl, killFactForBody, latestBodyFact, latestKillFact, latestSessionFact, lastAnsweredTurnFact, pauseFact, sessionFactForCoordinate, stopFact, } from "./rows.js";
2
3
  import { insertTellDeliveryFact, insertTellFact, insertTellReceiptFact, pendingTellFacts, tellFact, tellIdsForFence, } from "./tells.js";
3
4
  import { activityFactSlice, lastActivityAt as readLastActivityAt, pruneActivityFacts, } from "./timeline.js";
4
- import { isHeartAbsent, readSealFromLeash, readTransaction, transaction, withHeart } from "./storage.js";
5
+ import { isHeartAbsent, readSealFromLeash, readTransaction, transaction, withHeart, withReadOnlyHeart, } from "./storage.js";
5
6
  import { soulFact } from "./soul.js";
6
7
  export { HeartAbsentError, HeldAkumaLeash, classifyHeartSchema, initializeHeart, isHeartAbsent, probeLeash, watchHeart, } from "./storage.js";
7
8
  export { admitRequest, readNonterminalRequests, readRequest, refuseRequest, reserveRequest, serveRequest, serveUpstreamRequest, voidRequest, } from "./request-authority.js";
@@ -162,7 +163,7 @@ export async function finishBodyIfIdle(paths, input) {
162
163
  }
163
164
  export async function readHeart(paths) {
164
165
  try {
165
- return await withHeart(paths, (heart) => readTransaction(heart, () => ({
166
+ return await withReadOnlyHeart(paths, (heart) => readTransaction(heart, () => ({
166
167
  soul: soulFact(heart),
167
168
  latestBody: latestBodyFact(heart),
168
169
  latestSession: latestSessionFact(heart),
@@ -193,9 +194,12 @@ export async function readLastAnsweredTurn(paths) {
193
194
  return await withHeart(paths, lastAnsweredTurnFact);
194
195
  }
195
196
  export async function readForkPoint(paths, historyId) {
197
+ const turnSequence = parsePublicHistoryId(historyId);
198
+ if (turnSequence === null)
199
+ return null;
196
200
  try {
197
201
  return await withHeart(paths, (heart) => {
198
- const turn = answeredTurnFact(heart, historyId);
202
+ const turn = answeredTurnFact(heart, turnSequence);
199
203
  const outcome = turn?.end?.outcome;
200
204
  if (outcome?.kind !== "answered" || outcome.historyId === undefined)
201
205
  return null;
@@ -116,4 +116,4 @@ export declare function sessionFactForCoordinate(database: DatabaseSync, coordin
116
116
  export declare function latestKillFact(database: DatabaseSync): KillFact | null;
117
117
  export declare function killFactForBody(database: DatabaseSync, bodySequence: number): KillFact | null;
118
118
  export declare function lastAnsweredTurnFact(database: DatabaseSync): TurnFact | null;
119
- export declare function answeredTurnFact(database: DatabaseSync, historyId: string): TurnFact | null;
119
+ export declare function answeredTurnFact(database: DatabaseSync, turnSequence: number): TurnFact | null;
@@ -263,10 +263,10 @@ export function lastAnsweredTurnFact(database) {
263
263
  .get();
264
264
  return row === undefined ? null : decodeTurnRow(row);
265
265
  }
266
- export function answeredTurnFact(database, historyId) {
266
+ export function answeredTurnFact(database, turnSequence) {
267
267
  const row = database
268
268
  .prepare(`SELECT sequence, body_sequence, started_at, end_sequence, outcome, history_id,
269
- session_json, answer, diagnostic, completed_at FROM turns WHERE outcome = 'answered' AND history_id = ?`)
270
- .get(historyId);
269
+ session_json, answer, diagnostic, completed_at FROM turns WHERE outcome = 'answered' AND sequence = ?`)
270
+ .get(turnSequence);
271
271
  return row === undefined ? null : decodeTurnRow(row);
272
272
  }
@@ -10,6 +10,7 @@ export declare class HeartAbsentError extends Error {
10
10
  export declare function isHeartAbsent(error: unknown): error is HeartAbsentError;
11
11
  export declare function classifyHeartSchema(paths: AkumaPaths): Promise<"current" | "unsupported">;
12
12
  export declare function withHeart<T>(paths: AkumaPaths, body: (database: DatabaseSync) => T): Promise<T>;
13
+ export declare function withReadOnlyHeart<T>(paths: AkumaPaths, body: (database: DatabaseSync) => T): Promise<T>;
13
14
  export declare function transaction<T>(database: DatabaseSync, body: () => T): T;
14
15
  export declare function readTransaction<T>(database: DatabaseSync, body: () => T): T;
15
16
  export declare function initializeHeart(paths: AkumaPaths): Promise<void>;
@@ -12,7 +12,7 @@ const ACTIVITY_LIMIT = 5_000;
12
12
  const SQLITE_CANTOPEN = 14;
13
13
  export async function watchHeart(paths, signal) {
14
14
  const heart = basename(paths.heart);
15
- const changes = [];
15
+ let changed = false;
16
16
  let failure;
17
17
  let wake;
18
18
  // fs.watch opens the directory synchronously. Its throw is therefore the
@@ -20,7 +20,7 @@ export async function watchHeart(paths, signal) {
20
20
  const watcher = watchDirectory(dirname(paths.heart), { signal }, (_event, filename) => {
21
21
  const name = filename?.toString();
22
22
  if (name === undefined || name === heart || name === `${heart}-wal`) {
23
- changes.push(undefined);
23
+ changed = true;
24
24
  wake?.();
25
25
  }
26
26
  });
@@ -36,8 +36,8 @@ export async function watchHeart(paths, signal) {
36
36
  return (async function* () {
37
37
  try {
38
38
  for (;;) {
39
- if (changes.length > 0) {
40
- changes.shift();
39
+ if (changed) {
40
+ changed = false;
41
41
  yield;
42
42
  continue;
43
43
  }
@@ -82,9 +82,9 @@ function isBusy(error) {
82
82
  message.includes("database is locked") ||
83
83
  message.includes("database is busy"));
84
84
  }
85
- async function openExistingDatabase(path, timeout) {
85
+ async function openExistingDatabase(path, timeout, mode = "rw") {
86
86
  const uri = pathToFileURL(path);
87
- uri.searchParams.set("mode", "rw");
87
+ uri.searchParams.set("mode", mode);
88
88
  try {
89
89
  return timeout === undefined ? new DatabaseSync(uri.href) : new DatabaseSync(uri.href, { timeout });
90
90
  }
@@ -115,6 +115,19 @@ async function openHeart(path, verify = true) {
115
115
  throw error;
116
116
  }
117
117
  }
118
+ async function openHeartReadOnly(path, verify = true) {
119
+ const database = await openExistingDatabase(path, undefined, "ro");
120
+ try {
121
+ database.exec("PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000");
122
+ if (verify)
123
+ assertHeartSchemaVersion(database);
124
+ return database;
125
+ }
126
+ catch (error) {
127
+ database.close();
128
+ throw error;
129
+ }
130
+ }
118
131
  export async function classifyHeartSchema(paths) {
119
132
  let database;
120
133
  try {
@@ -141,6 +154,15 @@ export async function withHeart(paths, body) {
141
154
  database.close();
142
155
  }
143
156
  }
157
+ export async function withReadOnlyHeart(paths, body) {
158
+ const database = await openHeartReadOnly(paths.heart);
159
+ try {
160
+ return body(database);
161
+ }
162
+ finally {
163
+ database.close();
164
+ }
165
+ }
144
166
  export function transaction(database, body) {
145
167
  database.exec("BEGIN IMMEDIATE");
146
168
  try {
@@ -2,6 +2,7 @@ declare const AKU_ID: unique symbol;
2
2
  export type AkuId = string & {
3
3
  readonly [AKU_ID]: true;
4
4
  };
5
+ export declare function parsePublicHistoryId(value: string): number | null;
5
6
  export type AkumaPaths = Readonly<{
6
7
  directory: string;
7
8
  heart: string;