@abaigram/abai-stub 0.0.0-stage → 0.2.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 (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +22 -2
  3. package/dist/audit.d.ts +102 -0
  4. package/dist/audit.d.ts.map +1 -0
  5. package/dist/audit.js +196 -0
  6. package/dist/audit.js.map +1 -0
  7. package/dist/cells/fit.d.ts +6 -0
  8. package/dist/cells/fit.d.ts.map +1 -0
  9. package/dist/cells/fit.js +44 -0
  10. package/dist/cells/fit.js.map +1 -0
  11. package/dist/content.d.ts +17 -0
  12. package/dist/content.d.ts.map +1 -0
  13. package/dist/content.js +71 -0
  14. package/dist/content.js.map +1 -0
  15. package/dist/endpoint.d.ts +90 -0
  16. package/dist/endpoint.d.ts.map +1 -0
  17. package/dist/endpoint.js +804 -0
  18. package/dist/endpoint.js.map +1 -0
  19. package/dist/entry.d.ts +36 -0
  20. package/dist/entry.d.ts.map +1 -0
  21. package/dist/entry.js +409 -0
  22. package/dist/entry.js.map +1 -0
  23. package/dist/env.d.ts +38 -0
  24. package/dist/env.d.ts.map +1 -0
  25. package/dist/env.js +69 -0
  26. package/dist/env.js.map +1 -0
  27. package/dist/harness-binding.d.ts +235 -0
  28. package/dist/harness-binding.d.ts.map +1 -0
  29. package/dist/harness-binding.js +618 -0
  30. package/dist/harness-binding.js.map +1 -0
  31. package/dist/index.d.ts +17 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +17 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/manifest/index.d.ts +3 -0
  36. package/dist/manifest/index.d.ts.map +1 -0
  37. package/dist/manifest/index.js +3 -0
  38. package/dist/manifest/index.js.map +1 -0
  39. package/dist/manifest/manifest.d.ts +177 -0
  40. package/dist/manifest/manifest.d.ts.map +1 -0
  41. package/dist/manifest/manifest.js +452 -0
  42. package/dist/manifest/manifest.js.map +1 -0
  43. package/dist/manifest/render.d.ts +6 -0
  44. package/dist/manifest/render.d.ts.map +1 -0
  45. package/dist/manifest/render.js +39 -0
  46. package/dist/manifest/render.js.map +1 -0
  47. package/dist/null-harness.d.ts +32 -0
  48. package/dist/null-harness.d.ts.map +1 -0
  49. package/dist/null-harness.js +162 -0
  50. package/dist/null-harness.js.map +1 -0
  51. package/dist/program.d.ts +255 -0
  52. package/dist/program.d.ts.map +1 -0
  53. package/dist/program.js +898 -0
  54. package/dist/program.js.map +1 -0
  55. package/dist/region.d.ts +72 -0
  56. package/dist/region.d.ts.map +1 -0
  57. package/dist/region.js +93 -0
  58. package/dist/region.js.map +1 -0
  59. package/dist/registry.d.ts +60 -0
  60. package/dist/registry.d.ts.map +1 -0
  61. package/dist/registry.js +105 -0
  62. package/dist/registry.js.map +1 -0
  63. package/dist/wire/descriptor.d.ts +38 -0
  64. package/dist/wire/descriptor.d.ts.map +1 -0
  65. package/dist/wire/descriptor.js +144 -0
  66. package/dist/wire/descriptor.js.map +1 -0
  67. package/dist/wire/framing.d.ts +11 -0
  68. package/dist/wire/framing.d.ts.map +1 -0
  69. package/dist/wire/framing.js +42 -0
  70. package/dist/wire/framing.js.map +1 -0
  71. package/dist/wire/index.d.ts +4 -0
  72. package/dist/wire/index.d.ts.map +1 -0
  73. package/dist/wire/index.js +4 -0
  74. package/dist/wire/index.js.map +1 -0
  75. package/dist/wire/protocol.d.ts +257 -0
  76. package/dist/wire/protocol.d.ts.map +1 -0
  77. package/dist/wire/protocol.js +17 -0
  78. package/dist/wire/protocol.js.map +1 -0
  79. package/package.json +36 -5
@@ -0,0 +1,90 @@
1
+ import type { Abaigram } from "./program.ts";
2
+ /**
3
+ * Where the socket goes when the run directory's own path is too long for
4
+ * one (103 bytes on macOS, and a run directory under a deep project path
5
+ * gets there): a private directory under the machine's temp dir. The
6
+ * descriptor in the run directory names the socket wherever it is, which is
7
+ * what its `socket` field is for, so discovery is unchanged.
8
+ */
9
+ export declare function shortSocketPath(pid: number): string;
10
+ export interface EndpointOptions {
11
+ /** Where the socket and the descriptor go. */
12
+ runDir: string;
13
+ /** The socket path; default `<runDir>/<pid>.sock`. */
14
+ socketPath?: string;
15
+ /** A connection whose unsent output exceeds this many bytes is dropped whole. Default 16 MiB. */
16
+ maxBuffered?: number;
17
+ /** An input line past this many bytes drops the connection. Default 64 MiB. */
18
+ maxLineBytes?: number;
19
+ }
20
+ /** Unsent bytes a connection may hold before it is dropped: a harness that cannot keep up is not the program's problem. */
21
+ export declare const DEFAULT_MAX_BUFFERED: number;
22
+ /** Consecutive claim timeouts after which a connection's claims are released: a harness that never answers holds nothing. */
23
+ export declare const CONSECUTIVE_TIMEOUTS_LIMIT = 3;
24
+ export declare class Endpoint {
25
+ readonly runDir: string;
26
+ readonly socketPath: string;
27
+ /** Resolves once the socket listens and the descriptor is written; rejects with why it could not. */
28
+ readonly ready: Promise<void>;
29
+ private readonly app;
30
+ private readonly maxBuffered;
31
+ private readonly maxLineBytes;
32
+ private server;
33
+ private readonly connections;
34
+ private readonly claimants;
35
+ private readonly hooks;
36
+ /** Open turns and touchpoints, replayed to a connection that arrives while they are open. */
37
+ private readonly openTurns;
38
+ private readonly openTouchpoints;
39
+ private readonly connectionWaiters;
40
+ private binding;
41
+ private unsubscribe;
42
+ private nextConnectionId;
43
+ private closed;
44
+ private readonly onExit;
45
+ private settleReady;
46
+ constructor(app: Abaigram, options: EndpointOptions);
47
+ private resolveReady;
48
+ private rejectReady;
49
+ get connectionCount(): number;
50
+ /** The ids of the open connections, in order of arrival. */
51
+ connectionIds(): string[];
52
+ private listen;
53
+ /** Resolves true as soon as a connection is open, or when one arrives within `timeoutMs`; false when none does. */
54
+ waitForConnection(timeoutMs: number): Promise<boolean>;
55
+ /** Stop listening, drop every connection, remove the socket and the descriptor. */
56
+ close(reason?: string): void;
57
+ private removeFiles;
58
+ private accept;
59
+ /** The one binding every connection shares, created by the first. Its own label is for acts no connection made. */
60
+ private bindingFor;
61
+ private hello;
62
+ private write;
63
+ private drop;
64
+ /** Forget the binding and everything pending on it, every timer included: a finished program must be free to exit. */
65
+ private releaseBinding;
66
+ /** The program detached the binding itself: whoever is still connected is told by the close. */
67
+ private onDetach;
68
+ private onLine;
69
+ private respond;
70
+ private failure;
71
+ private dispatch;
72
+ private subscribe;
73
+ private accepts;
74
+ private broadcastEvent;
75
+ private claim;
76
+ private release;
77
+ /** Free some of a connection's claims; what it was asked under them takes the default now. */
78
+ private releaseClaims;
79
+ private forget;
80
+ /** The program asked a decision: it goes to the claimant, or takes its default now. */
81
+ private onDecision;
82
+ /** The binding settled a decision by timeout (its deadline, or the claim's wait through `defaultDecision`). */
83
+ private onDecisionTimeout;
84
+ private onHookResponse;
85
+ private turnMessage;
86
+ private touchpointMessage;
87
+ private onTurn;
88
+ private onTouchpoint;
89
+ }
90
+ //# sourceMappingURL=endpoint.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"endpoint.d.ts","sourceRoot":"","sources":["../src/endpoint.ts"],"names":[],"mappings":"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","sourcesContent":["// The stub's sidecar endpoint (abaigrammar section 6.3): the receiving end a\n// harness in another process connects to. Wire protocol v1 over a Unix\n// socket, listening from startup so an outside harness reaches a running\n// program that never calls a touchpoint, and so a lost connection is\n// restored by the harness connecting again.\n//\n// One binding, many connections. The first connection attaches the program\n// (one DirectBinding, which every channel runs through); the last one\n// leaving detaches it, so what was open settles as detached. Every act is\n// audited under the connection that made it. Decisions go to the one\n// connection that claimed them; turns and touchpoints are broadcast, and\n// the first end settles one.\n//\n// Nothing here keeps the program alive, and nothing here lets it die\n// mid-touchpoint: the server and its sockets are unref'd, so a program whose\n// work is done exits with a harness still connected, while a pending\n// decision, turn or touchpoint holds a ref'd timer of its own in the binding.\nimport { Buffer } from \"node:buffer\";\nimport { existsSync, unlinkSync } from \"node:fs\";\nimport { createServer, type Server, type Socket } from \"node:net\";\nimport { tmpdir } from \"node:os\";\nimport { dirname, join } from \"node:path\";\nimport { isMessageRecord } from \"./audit.ts\";\nimport {\n\tCallError,\n\ttype CallErrorCode,\n\ttype Consent,\n\ttype DecisionRequest,\n\ttype DirectBinding,\n\ttype LiveEvent,\n\ttype TouchpointRequest,\n\ttype TurnRequest,\n} from \"./harness-binding.ts\";\nimport { durationMs } from \"./manifest/manifest.ts\";\nimport type { Abaigram } from \"./program.ts\";\nimport {\n\tassertSocketPathLength,\n\tattachCappedLineReader,\n\tDEFAULT_MAX_LINE_BYTES,\n\tdefaultSocketPath,\n\tEXTENSIONS,\n\tensureRunDir,\n\ttype HelloMessage,\n\tMAX_SOCKET_PATH_BYTES,\n\tPROTOCOL_VERSION,\n\tremoveDescriptor,\n\ttype WireErrorCode,\n\ttype WireResponse,\n\twriteDescriptor,\n} from \"./wire/index.ts\";\n\n/**\n * Where the socket goes when the run directory's own path is too long for\n * one (103 bytes on macOS, and a run directory under a deep project path\n * gets there): a private directory under the machine's temp dir. The\n * descriptor in the run directory names the socket wherever it is, which is\n * what its `socket` field is for, so discovery is unchanged.\n */\nexport function shortSocketPath(pid: number): string {\n\t// per user: the temp dir is shared on Linux, and the directory is made private to whoever creates it\n\tconst uid = typeof process.getuid === \"function\" ? String(process.getuid()) : \"u\";\n\treturn join(tmpdir(), `abai-sockets-${uid}`, `${pid}.sock`);\n}\n\nexport interface EndpointOptions {\n\t/** Where the socket and the descriptor go. */\n\trunDir: string;\n\t/** The socket path; default `<runDir>/<pid>.sock`. */\n\tsocketPath?: string;\n\t/** A connection whose unsent output exceeds this many bytes is dropped whole. Default 16 MiB. */\n\tmaxBuffered?: number;\n\t/** An input line past this many bytes drops the connection. Default 64 MiB. */\n\tmaxLineBytes?: number;\n}\n\n/** Unsent bytes a connection may hold before it is dropped: a harness that cannot keep up is not the program's problem. */\nexport const DEFAULT_MAX_BUFFERED = 16 * 1024 * 1024;\n/** Consecutive claim timeouts after which a connection's claims are released: a harness that never answers holds nothing. */\nexport const CONSECUTIVE_TIMEOUTS_LIMIT = 3;\n/** How long a socket gets to flush when the endpoint closes it. */\nconst CLOSE_GRACE_MS = 200;\n\ninterface Connection {\n\tid: string;\n\tsocket: Socket;\n\t/** The event filter: `include` restricts to the listed types; `exclude` drops them. */\n\tinclude: Set<string> | undefined;\n\texclude: Set<string>;\n\tclaims: Set<string>;\n\t/** A claim's own wait, never longer than the manifest's. */\n\tclaimTimeoutMs: number | undefined;\n\tconsecutiveTimeouts: number;\n\t/** Bumped on every timeout; a hook sent before the last timeout counts once with its batch. */\n\ttimeoutEpoch: number;\n\tdetachReader: () => void;\n}\n\ninterface PendingHook {\n\tid: string;\n\tname: string;\n\tconnection: Connection;\n\tepochAtSend: number;\n\t/** The claim's shorter wait, when it has one. */\n\ttimer: NodeJS.Timeout | undefined;\n}\n\nfunction errorMessage(error: unknown): string {\n\treturn error instanceof Error ? error.message : String(error);\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n\treturn typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\nfunction line(value: unknown): string {\n\treturn `${JSON.stringify(value)}\\n`;\n}\n\nexport class Endpoint {\n\treadonly runDir: string;\n\treadonly socketPath: string;\n\t/** Resolves once the socket listens and the descriptor is written; rejects with why it could not. */\n\treadonly ready: Promise<void>;\n\tprivate readonly app: Abaigram;\n\tprivate readonly maxBuffered: number;\n\tprivate readonly maxLineBytes: number;\n\tprivate server: Server | undefined;\n\tprivate readonly connections = new Set<Connection>();\n\tprivate readonly claimants = new Map<string, Connection>();\n\tprivate readonly hooks = new Map<string, PendingHook>();\n\t/** Open turns and touchpoints, replayed to a connection that arrives while they are open. */\n\tprivate readonly openTurns = new Map<string, TurnRequest>();\n\tprivate readonly openTouchpoints = new Map<string, TouchpointRequest>();\n\tprivate readonly connectionWaiters: Array<() => void> = [];\n\tprivate binding: DirectBinding | undefined;\n\tprivate unsubscribe: Array<() => void> = [];\n\tprivate nextConnectionId = 1;\n\tprivate closed = false;\n\tprivate readonly onExit: () => void;\n\tprivate settleReady: { resolve: () => void; reject: (error: Error) => void } | undefined;\n\n\tconstructor(app: Abaigram, options: EndpointOptions) {\n\t\tthis.app = app;\n\t\tthis.runDir = options.runDir;\n\t\tconst preferred = options.socketPath ?? defaultSocketPath(options.runDir, process.pid);\n\t\t// an explicit path is used as given; the default falls back to a short one when it does not fit\n\t\tthis.socketPath =\n\t\t\toptions.socketPath === undefined && Buffer.byteLength(preferred) > MAX_SOCKET_PATH_BYTES\n\t\t\t\t? shortSocketPath(process.pid)\n\t\t\t\t: preferred;\n\t\tthis.maxBuffered = options.maxBuffered ?? DEFAULT_MAX_BUFFERED;\n\t\tthis.maxLineBytes = options.maxLineBytes ?? DEFAULT_MAX_LINE_BYTES;\n\t\tthis.onExit = () => this.removeFiles();\n\t\tthis.ready = new Promise<void>((resolve, reject) => {\n\t\t\tthis.settleReady = { resolve, reject };\n\t\t});\n\t\tthis.listen();\n\t}\n\n\tprivate resolveReady(): void {\n\t\tconst settle = this.settleReady;\n\t\tthis.settleReady = undefined;\n\t\tsettle?.resolve();\n\t}\n\n\tprivate rejectReady(error: unknown): void {\n\t\tconst settle = this.settleReady;\n\t\tthis.settleReady = undefined;\n\t\tsettle?.reject(error instanceof Error ? error : new Error(String(error)));\n\t}\n\n\tget connectionCount(): number {\n\t\treturn this.connections.size;\n\t}\n\n\t/** The ids of the open connections, in order of arrival. */\n\tconnectionIds(): string[] {\n\t\treturn [...this.connections].map((connection) => connection.id);\n\t}\n\n\tprivate listen(): void {\n\t\ttry {\n\t\t\tassertSocketPathLength(this.socketPath);\n\t\t\tensureRunDir(this.runDir);\n\t\t\tif (this.socketPath !== defaultSocketPath(this.runDir, process.pid)) ensureRunDir(dirname(this.socketPath));\n\t\t\t// a socket file left by an earlier life of this very pid number: nobody else writes to this name.\n\t\t\t// (A second Abaigram under sidecar in the same process would take over the first one's socket here;\n\t\t\t// one program is one endpoint, and a test suite that builds several keeps them in turns mode.)\n\t\t\tif (existsSync(this.socketPath)) unlinkSync(this.socketPath);\n\t\t} catch (error) {\n\t\t\tthis.rejectReady(error);\n\t\t\treturn;\n\t\t}\n\t\tconst server = createServer((socket) => this.accept(socket));\n\t\tthis.server = server;\n\t\tserver.once(\"error\", (error) => this.rejectReady(error));\n\t\tserver.listen(this.socketPath, () => {\n\t\t\tserver.unref();\n\t\t\ttry {\n\t\t\t\twriteDescriptor(this.runDir, {\n\t\t\t\t\tpid: process.pid,\n\t\t\t\t\tsocket: this.socketPath,\n\t\t\t\t\tcwd: process.cwd(),\n\t\t\t\t\tname: this.app.name,\n\t\t\t\t\tmode: this.app.mode,\n\t\t\t\t\tstartedAt: new Date().toISOString(),\n\t\t\t\t});\n\t\t\t} catch (error) {\n\t\t\t\tserver.close();\n\t\t\t\tthis.rejectReady(error);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tprocess.once(\"exit\", this.onExit);\n\t\t\tthis.resolveReady();\n\t\t});\n\t}\n\n\t/** Resolves true as soon as a connection is open, or when one arrives within `timeoutMs`; false when none does. */\n\twaitForConnection(timeoutMs: number): Promise<boolean> {\n\t\tif (this.connections.size > 0) return Promise.resolve(true);\n\t\treturn new Promise<boolean>((resolve) => {\n\t\t\tlet settled = false;\n\t\t\tconst timer = setTimeout(() => {\n\t\t\t\tif (settled) return;\n\t\t\t\tsettled = true;\n\t\t\t\tconst index = this.connectionWaiters.indexOf(waiter);\n\t\t\t\tif (index !== -1) this.connectionWaiters.splice(index, 1);\n\t\t\t\tresolve(false);\n\t\t\t}, timeoutMs);\n\t\t\tconst waiter = (): void => {\n\t\t\t\tif (settled) return;\n\t\t\t\tsettled = true;\n\t\t\t\tclearTimeout(timer);\n\t\t\t\tresolve(true);\n\t\t\t};\n\t\t\tthis.connectionWaiters.push(waiter);\n\t\t});\n\t}\n\n\t/** Stop listening, drop every connection, remove the socket and the descriptor. */\n\tclose(reason = \"the endpoint closed\"): void {\n\t\tif (this.closed) return;\n\t\tthis.closed = true;\n\t\t// closed before it listened: whoever awaits `ready` is told, rather than left waiting\n\t\tthis.rejectReady(new Error(reason));\n\t\tfor (const connection of [...this.connections]) this.drop(connection, reason);\n\t\tthis.server?.close();\n\t\tthis.removeFiles();\n\t\tprocess.off(\"exit\", this.onExit);\n\t}\n\n\tprivate removeFiles(): void {\n\t\ttry {\n\t\t\tremoveDescriptor(this.runDir, process.pid);\n\t\t} catch {\n\t\t\t// nothing to remove\n\t\t}\n\t\ttry {\n\t\t\tif (existsSync(this.socketPath)) unlinkSync(this.socketPath);\n\t\t} catch {\n\t\t\t// a socket file that cannot be removed is pruned by the next listing\n\t\t}\n\t}\n\n\t// -----------------------------------------------------------------------\n\t// Connections\n\t// -----------------------------------------------------------------------\n\n\tprivate accept(socket: Socket): void {\n\t\tif (this.closed) {\n\t\t\tsocket.destroy();\n\t\t\treturn;\n\t\t}\n\t\t// a connected harness never holds the program open; what holds it is the program's own pending work\n\t\tsocket.unref();\n\t\tsocket.setNoDelay?.(true);\n\t\tconst connection: Connection = {\n\t\t\tid: `k${this.nextConnectionId++}`,\n\t\t\tsocket,\n\t\t\tinclude: undefined,\n\t\t\texclude: new Set(),\n\t\t\tclaims: new Set(),\n\t\t\tclaimTimeoutMs: undefined,\n\t\t\tconsecutiveTimeouts: 0,\n\t\t\ttimeoutEpoch: 0,\n\t\t\tdetachReader: () => {},\n\t\t};\n\t\tlet binding: DirectBinding;\n\t\ttry {\n\t\t\tbinding = this.bindingFor(connection);\n\t\t} catch (error) {\n\t\t\t// the program is attached some other way (an in-process harness): this connection cannot be served,\n\t\t\t// and the one line saying so is flushed before the socket ends\n\t\t\tsocket.end(\n\t\t\t\tline({ type: \"response\", command: \"hello\", success: false, error: errorMessage(error), code: \"error\" }),\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\t\tsocket.on(\"error\", () => this.drop(connection, \"the connection failed\"));\n\t\tsocket.on(\"close\", () => this.drop(connection, \"the connection closed\"));\n\t\tconnection.detachReader = attachCappedLineReader(\n\t\t\tsocket,\n\t\t\tthis.maxLineBytes,\n\t\t\t(text) => this.onLine(connection, text),\n\t\t\t() => this.drop(connection, `a line exceeded ${this.maxLineBytes} bytes`),\n\t\t);\n\t\tthis.connections.add(connection);\n\t\tthis.write(connection, line(this.hello(connection, binding)));\n\t\t// a harness that connects while the program waits at a touchpoint or a turn is told at once\n\t\tfor (const turn of this.openTurns.values()) this.write(connection, line(this.turnMessage(turn)));\n\t\tfor (const touchpoint of this.openTouchpoints.values()) {\n\t\t\tthis.write(connection, line(this.touchpointMessage(touchpoint)));\n\t\t}\n\t\tfor (const waiter of this.connectionWaiters.splice(0)) waiter();\n\t}\n\n\t/** The one binding every connection shares, created by the first. Its own label is for acts no connection made. */\n\tprivate bindingFor(_connection: Connection): DirectBinding {\n\t\tif (this.binding) return this.binding;\n\t\tconst binding = this.app.bind(\"endpoint\");\n\t\tthis.binding = binding;\n\t\tthis.unsubscribe = [\n\t\t\tbinding.on(\"event\", (event) => this.broadcastEvent(event)),\n\t\t\tbinding.on(\"decision\", (request) => this.onDecision(request)),\n\t\t\tbinding.on(\"decision_timeout\", (id, name) => this.onDecisionTimeout(id, name)),\n\t\t\tbinding.on(\"turn\", (request) => this.onTurn(request)),\n\t\t\tbinding.on(\"touchpoint\", (request) => this.onTouchpoint(request)),\n\t\t\tbinding.on(\"detach\", (reason) => this.onDetach(reason)),\n\t\t];\n\t\treturn binding;\n\t}\n\n\tprivate hello(connection: Connection, binding: DirectBinding): HelloMessage {\n\t\tconst { manifest } = this.app;\n\t\treturn {\n\t\t\ttype: \"hello\",\n\t\t\tprotocol: PROTOCOL_VERSION,\n\t\t\tpid: process.pid,\n\t\t\tcwd: process.cwd(),\n\t\t\tmode: this.app.mode,\n\t\t\tcapabilities: {\n\t\t\t\thooks: manifest.decisions.filter((d) => d.policy !== \"forbid\").map((d) => d.name),\n\t\t\t\tcommands: manifest.capabilities.filter((c) => c.policy !== \"forbid\").map((c) => c.name),\n\t\t\t},\n\t\t\tlastSeq: this.app.region.lastSeq,\n\t\t\tconnection: connection.id,\n\t\t\tabai: {\n\t\t\t\tname: manifest.name,\n\t\t\t\tabaigrammar: manifest.abaigrammar,\n\t\t\t\tmodes: [...manifest.modes],\n\t\t\t\taudit: binding.auditInfo(),\n\t\t\t\textensions: [...EXTENSIONS],\n\t\t\t},\n\t\t};\n\t}\n\n\tprivate write(connection: Connection, text: string): void {\n\t\tconst { socket } = connection;\n\t\tif (socket.destroyed || !socket.writable) return;\n\t\tsocket.write(text);\n\t\tif (socket.writableLength > this.maxBuffered) this.drop(connection, \"the connection fell too far behind\");\n\t}\n\n\tprivate drop(connection: Connection, reason: string): void {\n\t\tif (!this.connections.delete(connection)) return;\n\t\tconnection.detachReader();\n\t\tfor (const name of connection.claims) {\n\t\t\tif (this.claimants.get(name) === connection) this.claimants.delete(name);\n\t\t}\n\t\tconnection.claims.clear();\n\t\t// what this connection was asked, nobody will answer now\n\t\tfor (const hook of [...this.hooks.values()]) {\n\t\t\tif (hook.connection !== connection) continue;\n\t\t\tthis.forget(hook);\n\t\t\tthis.binding?.defaultDecision(hook.id, \"detached\", connection.id);\n\t\t}\n\t\tif (!connection.socket.destroyed) {\n\t\t\tconnection.socket.end();\n\t\t\tconst timer = setTimeout(() => connection.socket.destroy(), CLOSE_GRACE_MS);\n\t\t\ttimer.unref();\n\t\t}\n\t\tif (this.connections.size === 0 && this.binding) {\n\t\t\tconst binding = this.binding;\n\t\t\tthis.releaseBinding();\n\t\t\tbinding.detach(`the last harness connection closed (${reason})`);\n\t\t}\n\t}\n\n\t/** Forget the binding and everything pending on it, every timer included: a finished program must be free to exit. */\n\tprivate releaseBinding(): void {\n\t\tthis.binding = undefined;\n\t\tfor (const off of this.unsubscribe.splice(0)) off();\n\t\tfor (const hook of [...this.hooks.values()]) this.forget(hook);\n\t\tthis.openTurns.clear();\n\t\tthis.openTouchpoints.clear();\n\t}\n\n\t/** The program detached the binding itself: whoever is still connected is told by the close. */\n\tprivate onDetach(_reason: string): void {\n\t\tif (!this.binding) return;\n\t\tthis.releaseBinding();\n\t\tfor (const connection of [...this.connections]) this.drop(connection, \"the program detached\");\n\t}\n\n\t// -----------------------------------------------------------------------\n\t// Lines in\n\t// -----------------------------------------------------------------------\n\n\tprivate onLine(connection: Connection, text: string): void {\n\t\tif (text.trim() === \"\") return;\n\t\tlet message: unknown;\n\t\ttry {\n\t\t\tmessage = JSON.parse(text);\n\t\t} catch {\n\t\t\tthis.respond(connection, { type: \"response\", command: \"unknown\", success: false, error: \"invalid json\" });\n\t\t\treturn;\n\t\t}\n\t\tif (!isRecord(message)) {\n\t\t\tthis.respond(connection, { type: \"response\", command: \"unknown\", success: false, error: \"invalid message\" });\n\t\t\treturn;\n\t\t}\n\t\tconst reason = typeof message.reason === \"string\" ? message.reason : undefined;\n\t\tconst consent = isConsent(message.consent) ? message.consent : undefined;\n\t\tswitch (message.type) {\n\t\t\tcase \"request\": {\n\t\t\t\tconst id = message.id;\n\t\t\t\tif (typeof id !== \"string\" || id === \"\") {\n\t\t\t\t\tthis.respond(connection, {\n\t\t\t\t\t\ttype: \"response\",\n\t\t\t\t\t\tcommand: \"request\",\n\t\t\t\t\t\tsuccess: false,\n\t\t\t\t\t\terror: \"request id must be a non-empty string\",\n\t\t\t\t\t\tcode: \"invalid\",\n\t\t\t\t\t});\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tvoid this.dispatch(connection, id, message.command, reason, consent);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tcase \"hook_response\":\n\t\t\t\tthis.onHookResponse(connection, message.id, message.result, reason, consent);\n\t\t\t\treturn;\n\t\t\tcase \"turn_end\":\n\t\t\t\tif (typeof message.id === \"string\") {\n\t\t\t\t\tthis.openTurns.delete(message.id);\n\t\t\t\t\tthis.binding?.endTurn(\n\t\t\t\t\t\tmessage.id,\n\t\t\t\t\t\tconnection.id,\n\t\t\t\t\t\ttypeof message.cause === \"string\" ? message.cause : undefined,\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\treturn;\n\t\t\tcase \"touchpoint_end\":\n\t\t\t\tif (typeof message.id === \"string\") {\n\t\t\t\t\tthis.openTouchpoints.delete(message.id);\n\t\t\t\t\tthis.binding?.endTouchpoint(message.id);\n\t\t\t\t}\n\t\t\t\treturn;\n\t\t\tdefault:\n\t\t\t\tthis.respond(connection, {\n\t\t\t\t\ttype: \"response\",\n\t\t\t\t\t...(typeof message.id === \"string\" ? { id: message.id } : {}),\n\t\t\t\t\tcommand: \"unknown\",\n\t\t\t\t\tsuccess: false,\n\t\t\t\t\terror: `unknown message type: ${String(message.type)}`,\n\t\t\t\t\tcode: \"unknown\",\n\t\t\t\t});\n\t\t}\n\t}\n\n\tprivate respond(connection: Connection, response: WireResponse): void {\n\t\ttry {\n\t\t\tthis.write(connection, line(response));\n\t\t} catch (error) {\n\t\t\t// a result that cannot be serialized becomes an error response; the connection stays\n\t\t\tconst failed: WireResponse = {\n\t\t\t\ttype: \"response\",\n\t\t\t\t...(response.id === undefined ? {} : { id: response.id }),\n\t\t\t\tcommand: response.command,\n\t\t\t\tsuccess: false,\n\t\t\t\terror: `the response could not be serialized: ${errorMessage(error)}`,\n\t\t\t\tcode: \"error\",\n\t\t\t};\n\t\t\tthis.write(connection, line(failed));\n\t\t}\n\t}\n\n\tprivate failure(id: string, command: string, error: string, code: WireErrorCode): WireResponse {\n\t\treturn { type: \"response\", id, command, success: false, error, code };\n\t}\n\n\t/** Run one request, answer it when it settles. Never rejects. */\n\tprivate async dispatch(\n\t\tconnection: Connection,\n\t\tid: string,\n\t\tcommand: unknown,\n\t\treason: string | undefined,\n\t\tconsent: Consent | undefined,\n\t): Promise<void> {\n\t\tif (!isRecord(command) || typeof command.type !== \"string\") {\n\t\t\tthis.respond(connection, this.failure(id, \"unknown\", \"command must be an object with a type\", \"invalid\"));\n\t\t\treturn;\n\t\t}\n\t\tconst type = command.type;\n\t\tconst binding = this.binding;\n\t\tif (!binding) {\n\t\t\tthis.respond(connection, this.failure(id, type, \"the program is no longer attached\", \"error\"));\n\t\t\treturn;\n\t\t}\n\t\tlet response: WireResponse;\n\t\ttry {\n\t\t\tswitch (type) {\n\t\t\t\tcase \"ping\":\n\t\t\t\t\tresponse = {\n\t\t\t\t\t\ttype: \"response\",\n\t\t\t\t\t\tid,\n\t\t\t\t\t\tcommand: type,\n\t\t\t\t\t\tsuccess: true,\n\t\t\t\t\t\tdata: { pid: process.pid, ts: Date.now() },\n\t\t\t\t\t};\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"subscribe\":\n\t\t\t\t\tresponse = this.subscribe(connection, id, command);\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"claim_hooks\":\n\t\t\t\t\tresponse = this.claim(connection, id, command);\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"release_hooks\":\n\t\t\t\t\tresponse = this.release(connection, id, command);\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"get_manifest\":\n\t\t\t\t\tresponse = { type: \"response\", id, command: type, success: true, data: { manifest: this.app.manifest } };\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"get_content\": {\n\t\t\t\t\tconst content = binding.content();\n\t\t\t\t\tconst turns: Record<string, string> = {};\n\t\t\t\t\tfor (const turn of this.app.manifest.turns) {\n\t\t\t\t\t\tconst text = content.turn(turn.name);\n\t\t\t\t\t\tif (text !== undefined) turns[turn.name] = text;\n\t\t\t\t\t}\n\t\t\t\t\tresponse = {\n\t\t\t\t\t\ttype: \"response\",\n\t\t\t\t\t\tid,\n\t\t\t\t\t\tcommand: type,\n\t\t\t\t\t\tsuccess: true,\n\t\t\t\t\t\tdata: {\n\t\t\t\t\t\t\tidentity: content.identity,\n\t\t\t\t\t\t\t...(content.install === undefined ? {} : { install: content.install }),\n\t\t\t\t\t\t\t...(content.use === undefined ? {} : { use: content.use }),\n\t\t\t\t\t\t\tturns,\n\t\t\t\t\t\t},\n\t\t\t\t\t};\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t\tcase \"get_audit\": {\n\t\t\t\t\tconst limit = typeof command.limit === \"number\" && command.limit > 0 ? Math.floor(command.limit) : 100;\n\t\t\t\t\tresponse = {\n\t\t\t\t\t\ttype: \"response\",\n\t\t\t\t\t\tid,\n\t\t\t\t\t\tcommand: type,\n\t\t\t\t\t\tsuccess: true,\n\t\t\t\t\t\tdata: { ...binding.auditInfo(), ...binding.auditTail(limit) },\n\t\t\t\t\t};\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t\tcase \"record_message\": {\n\t\t\t\t\t// the audit line for a message over the run's network (the `messages` extension): not an act of control\n\t\t\t\t\t// the agent's reason rides the request envelope like every other act's, or sits in the record\n\t\t\t\t\tconst { type: _type, ...given } = command;\n\t\t\t\t\tconst record = given.reason === undefined && reason !== undefined ? { ...given, reason } : given;\n\t\t\t\t\tif (!isMessageRecord(record)) {\n\t\t\t\t\t\tresponse = this.failure(\n\t\t\t\t\t\t\tid,\n\t\t\t\t\t\t\ttype,\n\t\t\t\t\t\t\t\"record_message needs direction, peer, addr, network, kind, body and outcome\",\n\t\t\t\t\t\t\t\"invalid\",\n\t\t\t\t\t\t);\n\t\t\t\t\t\tbreak;\n\t\t\t\t\t}\n\t\t\t\t\tresponse = binding.recordMessage(record, connection.id)\n\t\t\t\t\t\t? { type: \"response\", id, command: type, success: true, data: {} }\n\t\t\t\t\t\t: this.failure(id, type, \"the audit log is closed or failed: the message was not recorded\", \"error\");\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t\tcase \"get_cells\": {\n\t\t\t\t\tconst reading = binding.readCells();\n\t\t\t\t\tconst cells: Record<string, unknown> = {};\n\t\t\t\t\tfor (const [name, cell] of Object.entries(reading.cells)) cells[name] = cell.value;\n\t\t\t\t\tresponse = { type: \"response\", id, command: type, success: true, data: { seq: reading.seq, cells } };\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t\tcase \"lifecycle\": {\n\t\t\t\t\tif (typeof command.op !== \"string\") {\n\t\t\t\t\t\tresponse = this.failure(id, type, \"lifecycle needs an op\", \"invalid\");\n\t\t\t\t\t\tbreak;\n\t\t\t\t\t}\n\t\t\t\t\tconst outcome = binding.lifecycle(command.op, reason ?? \"\", consent, connection.id);\n\t\t\t\t\tresponse = outcome.ok\n\t\t\t\t\t\t? {\n\t\t\t\t\t\t\t\ttype: \"response\",\n\t\t\t\t\t\t\t\tid,\n\t\t\t\t\t\t\t\tcommand: type,\n\t\t\t\t\t\t\t\tsuccess: true,\n\t\t\t\t\t\t\t\tdata: outcome.data ?? { op: command.op, done: true },\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t: this.failure(id, type, outcome.error, outcome.code);\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t\tcase \"set_cell\": {\n\t\t\t\t\tif (typeof command.name !== \"string\") {\n\t\t\t\t\t\tresponse = this.failure(id, type, \"set_cell needs a cell name\", \"invalid\");\n\t\t\t\t\t\tbreak;\n\t\t\t\t\t}\n\t\t\t\t\tconst outcome = await binding.writeKnob(\n\t\t\t\t\t\tcommand.name,\n\t\t\t\t\t\tcommand.value,\n\t\t\t\t\t\treason ?? \"\",\n\t\t\t\t\t\tconsent,\n\t\t\t\t\t\tconnection.id,\n\t\t\t\t\t);\n\t\t\t\t\tresponse =\n\t\t\t\t\t\toutcome.outcome === \"done\"\n\t\t\t\t\t\t\t? {\n\t\t\t\t\t\t\t\t\ttype: \"response\",\n\t\t\t\t\t\t\t\t\tid,\n\t\t\t\t\t\t\t\t\tcommand: type,\n\t\t\t\t\t\t\t\t\tsuccess: true,\n\t\t\t\t\t\t\t\t\tdata: { name: command.name, value: outcome.value },\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t: this.failure(\n\t\t\t\t\t\t\t\t\tid,\n\t\t\t\t\t\t\t\t\ttype,\n\t\t\t\t\t\t\t\t\toutcome.error ?? `${command.name} was not written`,\n\t\t\t\t\t\t\t\t\toutcome.code ?? \"error\",\n\t\t\t\t\t\t\t\t);\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t\tdefault: {\n\t\t\t\t\tconst { type: _type, ...params } = command;\n\t\t\t\t\tconst result = await binding.call(type, params, reason ?? \"\", consent, connection.id);\n\t\t\t\t\tresponse = { type: \"response\", id, command: type, success: true, data: result };\n\t\t\t\t}\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tconst code: CallErrorCode = error instanceof CallError ? error.code : \"error\";\n\t\t\tresponse = this.failure(id, type, errorMessage(error), code);\n\t\t}\n\t\tthis.respond(connection, response);\n\t}\n\n\tprivate subscribe(connection: Connection, id: string, command: Record<string, unknown>): WireResponse {\n\t\tconst names = (value: unknown): string[] | undefined =>\n\t\t\tArray.isArray(value) && value.every((v) => typeof v === \"string\") ? (value as string[]) : undefined;\n\t\tconst include = command.include === undefined ? undefined : names(command.include);\n\t\tconst exclude = command.exclude === undefined ? [] : names(command.exclude);\n\t\tif ((command.include !== undefined && include === undefined) || exclude === undefined) {\n\t\t\treturn this.failure(id, \"subscribe\", \"include and exclude must be arrays of event names\", \"invalid\");\n\t\t}\n\t\tconnection.include = include === undefined ? undefined : new Set(include);\n\t\tconnection.exclude = new Set(exclude);\n\t\treturn {\n\t\t\ttype: \"response\",\n\t\t\tid,\n\t\t\tcommand: \"subscribe\",\n\t\t\tsuccess: true,\n\t\t\tdata: { ...(include === undefined ? {} : { include }), exclude },\n\t\t};\n\t}\n\n\t// -----------------------------------------------------------------------\n\t// Events\n\t// -----------------------------------------------------------------------\n\n\tprivate accepts(connection: Connection, name: string): boolean {\n\t\tif (connection.include !== undefined && !connection.include.has(name)) return false;\n\t\treturn !connection.exclude.has(name);\n\t}\n\n\tprivate broadcastEvent(event: LiveEvent): void {\n\t\tconst targets = [...this.connections].filter((connection) => this.accepts(connection, event.name));\n\t\tif (targets.length === 0) return;\n\t\tlet text: string;\n\t\ttry {\n\t\t\ttext = line({\n\t\t\t\ttype: \"event\",\n\t\t\t\tseq: event.seq,\n\t\t\t\tts: event.ts,\n\t\t\t\tevent: { type: event.name, ...(event.payload ?? {}) },\n\t\t\t\t...(event.truncated ? { truncated: true } : {}),\n\t\t\t});\n\t\t} catch (error) {\n\t\t\ttext = line({\n\t\t\t\ttype: \"serialize_error\",\n\t\t\t\tseq: event.seq,\n\t\t\t\tts: event.ts,\n\t\t\t\teventType: event.name,\n\t\t\t\terror: errorMessage(error),\n\t\t\t});\n\t\t}\n\t\tfor (const connection of targets) this.write(connection, text);\n\t}\n\n\t// -----------------------------------------------------------------------\n\t// Decisions as hooks\n\t// -----------------------------------------------------------------------\n\n\tprivate claim(connection: Connection, id: string, command: Record<string, unknown>): WireResponse {\n\t\tconst hooks = command.hooks;\n\t\tconst declared = new Map(this.app.manifest.decisions.map((d) => [d.name, d]));\n\t\tif (!Array.isArray(hooks) || hooks.length === 0 || !hooks.every((h) => typeof h === \"string\")) {\n\t\t\treturn this.failure(id, \"claim_hooks\", \"hooks must be a non-empty array of decision names\", \"invalid\");\n\t\t}\n\t\tconst timeoutMs = command.timeoutMs;\n\t\tif (timeoutMs !== undefined && (typeof timeoutMs !== \"number\" || !Number.isInteger(timeoutMs) || timeoutMs < 1)) {\n\t\t\treturn this.failure(id, \"claim_hooks\", \"timeoutMs must be a positive integer\", \"invalid\");\n\t\t}\n\t\t// all or nothing: every name is checked before any is taken\n\t\tfor (const name of hooks as string[]) {\n\t\t\tconst decl = declared.get(name);\n\t\t\tif (!decl) return this.failure(id, \"claim_hooks\", `unknown decision: ${name}`, \"unknown\");\n\t\t\tif (decl.policy === \"forbid\") {\n\t\t\t\treturn this.failure(id, \"claim_hooks\", `decision ${name} is forbidden by policy`, \"forbidden\");\n\t\t\t}\n\t\t\tconst holder = this.claimants.get(name);\n\t\t\tif (holder !== undefined && holder !== connection) {\n\t\t\t\treturn this.failure(id, \"claim_hooks\", `decision ${name} is held by connection ${holder.id}`, \"denied\");\n\t\t\t}\n\t\t}\n\t\tfor (const name of hooks as string[]) {\n\t\t\tthis.claimants.set(name, connection);\n\t\t\tconnection.claims.add(name);\n\t\t}\n\t\t// the wait in force: the claim's own, which may only shorten the shortest declared among what it holds\n\t\tconst shortest = Math.min(\n\t\t\t...[...connection.claims].map((name) => durationMs(declared.get(name)?.timeout ?? \"30s\")),\n\t\t);\n\t\tif (timeoutMs !== undefined) connection.claimTimeoutMs = Math.min(timeoutMs, shortest);\n\t\treturn {\n\t\t\ttype: \"response\",\n\t\t\tid,\n\t\t\tcommand: \"claim_hooks\",\n\t\t\tsuccess: true,\n\t\t\tdata: { hooks: [...connection.claims], timeoutMs: Math.min(connection.claimTimeoutMs ?? shortest, shortest) },\n\t\t};\n\t}\n\n\tprivate release(connection: Connection, id: string, command: Record<string, unknown>): WireResponse {\n\t\tlet names: string[];\n\t\tif (command.hooks === undefined) names = [...connection.claims];\n\t\telse if (Array.isArray(command.hooks) && command.hooks.every((h) => typeof h === \"string\")) {\n\t\t\tnames = command.hooks as string[];\n\t\t} else return this.failure(id, \"release_hooks\", \"hooks must be an array of decision names\", \"invalid\");\n\t\tconst released = this.releaseClaims(connection, names);\n\t\treturn { type: \"response\", id, command: \"release_hooks\", success: true, data: { released } };\n\t}\n\n\t/** Free some of a connection's claims; what it was asked under them takes the default now. */\n\tprivate releaseClaims(connection: Connection, names: string[]): string[] {\n\t\tconst released: string[] = [];\n\t\tfor (const name of names) {\n\t\t\tif (!connection.claims.has(name)) continue;\n\t\t\tconnection.claims.delete(name);\n\t\t\tif (this.claimants.get(name) === connection) this.claimants.delete(name);\n\t\t\treleased.push(name);\n\t\t}\n\t\tfor (const hook of [...this.hooks.values()]) {\n\t\t\tif (hook.connection !== connection || !released.includes(hook.name)) continue;\n\t\t\tthis.forget(hook);\n\t\t\t// for this decision its claimant is gone, which is what detached means (section 5.4)\n\t\t\tthis.binding?.defaultDecision(hook.id, \"detached\", connection.id);\n\t\t}\n\t\treturn released;\n\t}\n\n\tprivate forget(hook: PendingHook): void {\n\t\tthis.hooks.delete(hook.id);\n\t\tif (hook.timer !== undefined) clearTimeout(hook.timer);\n\t}\n\n\t/** The program asked a decision: it goes to the claimant, or takes its default now. */\n\tprivate onDecision(request: DecisionRequest): void {\n\t\tconst binding = this.binding;\n\t\tif (!binding) return;\n\t\tconst connection = this.claimants.get(request.name);\n\t\tif (!connection) {\n\t\t\tbinding.defaultDecision(request.id, \"no_harness\");\n\t\t\treturn;\n\t\t}\n\t\tbinding.assignDecision(request.id, connection.id);\n\t\tconst remaining = Math.max(0, request.deadline - this.app.now());\n\t\tconst timeoutMs =\n\t\t\tconnection.claimTimeoutMs === undefined ? remaining : Math.min(remaining, connection.claimTimeoutMs);\n\t\tlet text: string;\n\t\ttry {\n\t\t\ttext = line({ type: \"hook\", id: request.id, hook: request.name, timeoutMs, payload: request.context });\n\t\t} catch (error) {\n\t\t\t// the context cannot cross: the claimant is told, and the program goes on with the default\n\t\t\tconst message = `payload could not be serialized: ${errorMessage(error)}`;\n\t\t\tthis.write(connection, line({ type: \"hook_rejected\", id: request.id, hook: request.name, error: message }));\n\t\t\tbinding.defaultDecision(request.id, \"invalid\", connection.id);\n\t\t\treturn;\n\t\t}\n\t\tconst hook: PendingHook = {\n\t\t\tid: request.id,\n\t\t\tname: request.name,\n\t\t\tconnection,\n\t\t\tepochAtSend: connection.timeoutEpoch,\n\t\t\ttimer: undefined,\n\t\t};\n\t\tif (timeoutMs < remaining) {\n\t\t\t// the claim's own wait, shorter than the manifest's: the default is applied then, through the one path\n\t\t\thook.timer = setTimeout(() => binding.defaultDecision(request.id, \"timeout\", connection.id), timeoutMs);\n\t\t}\n\t\tthis.hooks.set(request.id, hook);\n\t\tthis.write(connection, text);\n\t}\n\n\t/** The binding settled a decision by timeout (its deadline, or the claim's wait through `defaultDecision`). */\n\tprivate onDecisionTimeout(id: string, name: string): void {\n\t\tconst hook = this.hooks.get(id);\n\t\tif (!hook) return;\n\t\tthis.forget(hook);\n\t\tconst { connection } = hook;\n\t\tthis.write(connection, line({ type: \"hook_timeout\", id, hook: name }));\n\t\t// a batch that timed out together counts once\n\t\tif (hook.epochAtSend === connection.timeoutEpoch) connection.consecutiveTimeouts += 1;\n\t\tconnection.timeoutEpoch += 1;\n\t\tif (connection.consecutiveTimeouts >= CONSECUTIVE_TIMEOUTS_LIMIT) {\n\t\t\tconnection.consecutiveTimeouts = 0;\n\t\t\tconst released = this.releaseClaims(connection, [...connection.claims]);\n\t\t\tthis.write(connection, line({ type: \"hook_claims_released\", reason: \"timeouts\", hooks: released }));\n\t\t}\n\t}\n\n\tprivate onHookResponse(\n\t\tconnection: Connection,\n\t\tid: unknown,\n\t\tresult: unknown,\n\t\treason: string | undefined,\n\t\tconsent: Consent | undefined,\n\t): void {\n\t\tif (typeof id !== \"string\") return;\n\t\tconst hook = this.hooks.get(id);\n\t\t// ignored unless this very connection was asked: a late or a stray answer writes nothing\n\t\tif (!hook || hook.connection !== connection) return;\n\t\tthis.forget(hook);\n\t\ttry {\n\t\t\tthis.binding?.answerDecision(\n\t\t\t\tid,\n\t\t\t\tresult ?? null,\n\t\t\t\treason ?? \"no reason stated by the harness\",\n\t\t\t\tconsent,\n\t\t\t\tconnection.id,\n\t\t\t);\n\t\t\t// only an accepted answer resets the streak\n\t\t\tconnection.consecutiveTimeouts = 0;\n\t\t} catch (error) {\n\t\t\tthis.write(connection, line({ type: \"hook_rejected\", id, hook: hook.name, error: errorMessage(error) }));\n\t\t}\n\t}\n\n\t// -----------------------------------------------------------------------\n\t// Turns and touchpoints, broadcast\n\t// -----------------------------------------------------------------------\n\n\tprivate turnMessage(request: TurnRequest) {\n\t\treturn {\n\t\t\ttype: \"turn\",\n\t\t\tid: request.id,\n\t\t\tname: request.name,\n\t\t\ttimeoutMs: Math.max(0, request.deadline - this.app.now()),\n\t\t\t...(request.content === undefined ? {} : { content: request.content }),\n\t\t};\n\t}\n\n\tprivate touchpointMessage(request: TouchpointRequest) {\n\t\treturn {\n\t\t\ttype: \"touchpoint\",\n\t\t\tid: request.id,\n\t\t\tkind: request.kind,\n\t\t\t...(request.content === undefined ? {} : { content: request.content }),\n\t\t};\n\t}\n\n\tprivate onTurn(request: TurnRequest): void {\n\t\tthis.openTurns.set(request.id, request);\n\t\t// a turn ends on its own at its deadline, and this map must not remember it past that\n\t\tconst timer = setTimeout(\n\t\t\t() => this.openTurns.delete(request.id),\n\t\t\tMath.max(0, request.deadline - this.app.now()) + 1,\n\t\t);\n\t\ttimer.unref();\n\t\tconst text = line(this.turnMessage(request));\n\t\tfor (const connection of [...this.connections]) this.write(connection, text);\n\t}\n\n\tprivate onTouchpoint(request: TouchpointRequest): void {\n\t\tthis.openTouchpoints.set(request.id, request);\n\t\tconst text = line(this.touchpointMessage(request));\n\t\tfor (const connection of [...this.connections]) this.write(connection, text);\n\t}\n}\n\nfunction isConsent(value: unknown): value is Consent {\n\treturn value === \"approved\" || value === \"auto\" || value === \"denied\";\n}\n"]}