@animalabs/agent-framework 0.14.0 → 0.16.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 (134) hide show
  1. package/README.md +22 -1
  2. package/dist/src/agent.d.ts +42 -0
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +94 -0
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/api/server.d.ts +9 -0
  7. package/dist/src/api/server.d.ts.map +1 -1
  8. package/dist/src/api/server.js +156 -0
  9. package/dist/src/api/server.js.map +1 -1
  10. package/dist/src/api/types.d.ts +15 -0
  11. package/dist/src/api/types.d.ts.map +1 -1
  12. package/dist/src/framework.d.ts +423 -5
  13. package/dist/src/framework.d.ts.map +1 -1
  14. package/dist/src/framework.js +1906 -102
  15. package/dist/src/framework.js.map +1 -1
  16. package/dist/src/gate/event-gate.d.ts +16 -0
  17. package/dist/src/gate/event-gate.d.ts.map +1 -1
  18. package/dist/src/gate/event-gate.js +41 -3
  19. package/dist/src/gate/event-gate.js.map +1 -1
  20. package/dist/src/index.d.ts +4 -1
  21. package/dist/src/index.d.ts.map +1 -1
  22. package/dist/src/index.js +3 -1
  23. package/dist/src/index.js.map +1 -1
  24. package/dist/src/mcpl/channel-registry.d.ts +172 -0
  25. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  26. package/dist/src/mcpl/channel-registry.js +444 -2
  27. package/dist/src/mcpl/channel-registry.js.map +1 -1
  28. package/dist/src/mcpl/server-connection.d.ts +9 -0
  29. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  30. package/dist/src/mcpl/server-connection.js +71 -13
  31. package/dist/src/mcpl/server-connection.js.map +1 -1
  32. package/dist/src/modules/history/index.d.ts +145 -0
  33. package/dist/src/modules/history/index.d.ts.map +1 -0
  34. package/dist/src/modules/history/index.js +1150 -0
  35. package/dist/src/modules/history/index.js.map +1 -0
  36. package/dist/src/modules/history/search-regex-worker.d.ts +23 -0
  37. package/dist/src/modules/history/search-regex-worker.d.ts.map +1 -0
  38. package/dist/src/modules/history/search-regex-worker.js +57 -0
  39. package/dist/src/modules/history/search-regex-worker.js.map +1 -0
  40. package/dist/src/modules/index.d.ts +1 -0
  41. package/dist/src/modules/index.d.ts.map +1 -1
  42. package/dist/src/modules/index.js +1 -0
  43. package/dist/src/modules/index.js.map +1 -1
  44. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  45. package/dist/src/modules/workspace/index.js +42 -1
  46. package/dist/src/modules/workspace/index.js.map +1 -1
  47. package/dist/src/modules/workspace/types.d.ts +5 -0
  48. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  49. package/dist/src/modules/workspace/types.js.map +1 -1
  50. package/dist/src/operator-log.d.ts +78 -0
  51. package/dist/src/operator-log.d.ts.map +1 -0
  52. package/dist/src/operator-log.js +102 -0
  53. package/dist/src/operator-log.js.map +1 -0
  54. package/dist/src/recovery/discord-awareness-outbox.d.ts +10 -0
  55. package/dist/src/recovery/discord-awareness-outbox.d.ts.map +1 -1
  56. package/dist/src/recovery/discord-awareness-outbox.js +20 -0
  57. package/dist/src/recovery/discord-awareness-outbox.js.map +1 -1
  58. package/dist/src/types/framework.d.ts +24 -0
  59. package/dist/src/types/framework.d.ts.map +1 -1
  60. package/dist/src/types/trace.d.ts +40 -0
  61. package/dist/src/types/trace.d.ts.map +1 -1
  62. package/dist/test/agent-runtime-settings.test.js +199 -1
  63. package/dist/test/agent-runtime-settings.test.js.map +1 -1
  64. package/dist/test/api-admin-gate.test.d.ts +2 -0
  65. package/dist/test/api-admin-gate.test.d.ts.map +1 -0
  66. package/dist/test/api-admin-gate.test.js +116 -0
  67. package/dist/test/api-admin-gate.test.js.map +1 -0
  68. package/dist/test/channel-label-history.test.d.ts +2 -0
  69. package/dist/test/channel-label-history.test.d.ts.map +1 -0
  70. package/dist/test/channel-label-history.test.js +424 -0
  71. package/dist/test/channel-label-history.test.js.map +1 -0
  72. package/dist/test/discord-awareness-outbox.test.js +16 -0
  73. package/dist/test/discord-awareness-outbox.test.js.map +1 -1
  74. package/dist/test/event-gate.test.js +44 -0
  75. package/dist/test/event-gate.test.js.map +1 -1
  76. package/dist/test/helpers/quiesce-crash-child.d.ts +2 -0
  77. package/dist/test/helpers/quiesce-crash-child.d.ts.map +1 -0
  78. package/dist/test/helpers/quiesce-crash-child.js +156 -0
  79. package/dist/test/helpers/quiesce-crash-child.js.map +1 -0
  80. package/dist/test/history-module-overview.test.d.ts +2 -0
  81. package/dist/test/history-module-overview.test.d.ts.map +1 -0
  82. package/dist/test/history-module-overview.test.js +711 -0
  83. package/dist/test/history-module-overview.test.js.map +1 -0
  84. package/dist/test/history-module.test.d.ts +2 -0
  85. package/dist/test/history-module.test.d.ts.map +1 -0
  86. package/dist/test/history-module.test.js +595 -0
  87. package/dist/test/history-module.test.js.map +1 -0
  88. package/dist/test/host-quiesce.test.d.ts +2 -0
  89. package/dist/test/host-quiesce.test.d.ts.map +1 -0
  90. package/dist/test/host-quiesce.test.js +863 -0
  91. package/dist/test/host-quiesce.test.js.map +1 -0
  92. package/dist/test/kv-unified-open-flight-retry.test.d.ts +2 -0
  93. package/dist/test/kv-unified-open-flight-retry.test.d.ts.map +1 -0
  94. package/dist/test/kv-unified-open-flight-retry.test.js +189 -0
  95. package/dist/test/kv-unified-open-flight-retry.test.js.map +1 -0
  96. package/dist/test/kv-unified-wiring.test.js +39 -0
  97. package/dist/test/kv-unified-wiring.test.js.map +1 -1
  98. package/dist/test/operator-surgery.test.d.ts +2 -0
  99. package/dist/test/operator-surgery.test.d.ts.map +1 -0
  100. package/dist/test/operator-surgery.test.js +398 -0
  101. package/dist/test/operator-surgery.test.js.map +1 -0
  102. package/dist/test/puppet-tool-call.test.js +118 -1
  103. package/dist/test/puppet-tool-call.test.js.map +1 -1
  104. package/dist/test/puppet-turn-reservation.test.d.ts +2 -0
  105. package/dist/test/puppet-turn-reservation.test.d.ts.map +1 -0
  106. package/dist/test/puppet-turn-reservation.test.js +139 -0
  107. package/dist/test/puppet-turn-reservation.test.js.map +1 -0
  108. package/dist/test/save-recent-image.test.js +29 -0
  109. package/dist/test/save-recent-image.test.js.map +1 -1
  110. package/dist/test/tool-result-spill.test.js +114 -0
  111. package/dist/test/tool-result-spill.test.js.map +1 -1
  112. package/dist/test/workspace-read-text.test.d.ts +2 -0
  113. package/dist/test/workspace-read-text.test.d.ts.map +1 -0
  114. package/dist/test/workspace-read-text.test.js +101 -0
  115. package/dist/test/workspace-read-text.test.js.map +1 -0
  116. package/dist/tsconfig.tsbuildinfo +1 -1
  117. package/package.json +3 -3
  118. package/src/agent.ts +110 -0
  119. package/src/api/server.ts +159 -2
  120. package/src/api/types.ts +15 -0
  121. package/src/framework.ts +2196 -115
  122. package/src/gate/event-gate.ts +47 -4
  123. package/src/index.ts +5 -1
  124. package/src/mcpl/channel-registry.ts +495 -2
  125. package/src/mcpl/server-connection.ts +73 -13
  126. package/src/modules/history/index.ts +1277 -0
  127. package/src/modules/history/search-regex-worker.ts +76 -0
  128. package/src/modules/index.ts +2 -0
  129. package/src/modules/workspace/index.ts +42 -1
  130. package/src/modules/workspace/types.ts +5 -0
  131. package/src/operator-log.ts +132 -0
  132. package/src/recovery/discord-awareness-outbox.ts +19 -0
  133. package/src/types/framework.ts +27 -0
  134. package/src/types/trace.ts +46 -0
@@ -0,0 +1,76 @@
1
+ /**
2
+ * search-regex-worker — runs a caller-supplied regex against a batch of
3
+ * message text on a separate thread, so HistoryModule's `search` tool can
4
+ * forcibly terminate a catastrophically-backtracking pattern (ReDoS)
5
+ * instead of blocking the framework's single-threaded event loop.
6
+ *
7
+ * Why a worker and not a Promise.race/setTimeout "timeout": JS is
8
+ * single-threaded, so nothing on the SAME thread can interrupt a
9
+ * synchronous RegExp.exec() call already in flight — a timeout callback
10
+ * racing it can't even fire until the blocking call returns, by which point
11
+ * it's too late. A worker thread is real OS-level concurrency: the parent's
12
+ * event loop keeps running, and `worker.terminate()` actually kills the
13
+ * stuck thread rather than just giving up on waiting for it.
14
+ *
15
+ * Only regex-mode search routes through this worker. Plain substring search
16
+ * (String.prototype.indexOf) is inherently linear in input length and has
17
+ * no equivalent risk, so it stays in-process — the common case pays no
18
+ * worker-spawn overhead.
19
+ *
20
+ * One-shot: the whole job (already-flattened text, already syntax-validated
21
+ * pattern/flags, match limit) arrives via `workerData` at construction; this
22
+ * script runs it to completion and posts exactly one message, then the
23
+ * parent always terminates the worker (whether it finished normally or had
24
+ * to be killed for running long) — no persistent pool, so a wedged worker
25
+ * from one bad pattern can never contaminate a later call.
26
+ */
27
+ import { parentPort, workerData } from 'node:worker_threads';
28
+
29
+ export interface SearchWorkerJob {
30
+ /** Flattened candidate text, aligned by index with the parent's candidate array. */
31
+ texts: string[];
32
+ pattern: string;
33
+ flags: string;
34
+ /** Stop scanning once this many matches are found. */
35
+ limit: number;
36
+ }
37
+
38
+ export interface SearchWorkerMatch {
39
+ /** Index into `texts` / the parent's candidate array. */
40
+ candidateIndex: number;
41
+ matchIndex: number;
42
+ matchLength: number;
43
+ }
44
+
45
+ export type SearchWorkerMessage =
46
+ | { type: 'done'; matches: SearchWorkerMatch[]; scanned: number }
47
+ | { type: 'error'; error: string };
48
+
49
+ const { texts, pattern, flags, limit } = workerData as SearchWorkerJob;
50
+
51
+ try {
52
+ // Re-construct (not re-validate for safety — RegExp syntax is what it is;
53
+ // the parent already rejected an uncompilable pattern before ever getting
54
+ // here) the matcher on this thread. Compilation succeeding doesn't bound
55
+ // MATCHING time — that's exactly the risk this worker exists to contain.
56
+ const matcher = new RegExp(pattern, flags);
57
+ const matches: SearchWorkerMatch[] = [];
58
+ let scanned = 0;
59
+ for (let i = 0; i < texts.length; i++) {
60
+ // Check the limit BEFORE doing any work for this candidate — not after
61
+ // pushing a match — so limit:0 (a valid clampCount value: it's >= 0)
62
+ // correctly yields zero matches instead of one. Checking post-push would
63
+ // always let through the match that first reaches the limit.
64
+ if (matches.length >= limit) break;
65
+ scanned++;
66
+ const m = matcher.exec(texts[i] ?? '');
67
+ if (m) {
68
+ matches.push({ candidateIndex: i, matchIndex: m.index, matchLength: m[0].length });
69
+ }
70
+ }
71
+ const done: SearchWorkerMessage = { type: 'done', matches, scanned };
72
+ parentPort?.postMessage(done);
73
+ } catch (e) {
74
+ const errMsg: SearchWorkerMessage = { type: 'error', error: e instanceof Error ? e.message : String(e) };
75
+ parentPort?.postMessage(errMsg);
76
+ }
@@ -8,6 +8,8 @@ export type { ApiEvent } from './api/index.js';
8
8
  export { HealthModule } from './health/index.js';
9
9
  export type { HealthModuleConfig } from './health/index.js';
10
10
 
11
+ export { HistoryModule } from './history/index.js';
12
+
11
13
  export { WorkspaceModule, WorkspaceReadError } from './workspace/index.js';
12
14
  export type { WorkspaceReadErrorCode, WorkspaceReadStage, WorkspaceDiskReadResult, ReadFileFromDiskOptions } from './workspace/index.js';
13
15
  export type {
@@ -932,13 +932,19 @@ export class WorkspaceModule implements Module {
932
932
  return [
933
933
  {
934
934
  name: 'read',
935
- description: 'Read a file from the workspace. Returns content with line numbers. Reads at most 2000 lines per call by default — use offset/limit to page through larger files.',
935
+ description: 'Read a file from the workspace. Returns content with line numbers. '
936
+ + 'Reads at most 2000 lines per call by default — use offset/limit to page through larger files. '
937
+ + 'For long lines or spill files, use offsetChars/limitChars instead: returns raw text without '
938
+ + 'line numbers, with nextOffsetChars (null at EOF). Keep limitChars when continuing. '
939
+ + 'Character offsets count UTF-16 code units; do not mix character and line parameters.',
936
940
  inputSchema: {
937
941
  type: 'object' as const,
938
942
  properties: {
939
943
  path: { type: 'string', description: 'File path (mount-prefixed, e.g., "project/src/main.ts")' },
940
944
  offset: { type: 'number', description: 'Starting line number (1-indexed)' },
941
945
  limit: { type: 'number', description: 'Maximum number of lines to return (default 2000)' },
946
+ offsetChars: { type: 'integer', description: 'Character paging: zero-based UTF-16 offset (default 0); use nextOffsetChars to continue' },
947
+ limitChars: { type: 'integer', description: 'Character paging: code units to return (default 2000), plus at most one to keep a surrogate pair intact. Use the limit suggested by a spill notice.' },
942
948
  },
943
949
  required: ['path'],
944
950
  },
@@ -1741,6 +1747,19 @@ export class WorkspaceModule implements Module {
1741
1747
  // ==========================================================================
1742
1748
 
1743
1749
  private async handleRead(input: ReadInput): Promise<ToolResult> {
1750
+ const characterPaging = input.offsetChars !== undefined || input.limitChars !== undefined;
1751
+ const offsetChars = input.offsetChars ?? 0;
1752
+ const limitChars = input.limitChars ?? 2000;
1753
+ if (characterPaging) {
1754
+ if (input.offset !== undefined || input.limit !== undefined) {
1755
+ return { success: false, isError: true, error: 'Use either offset/limit (lines) or offsetChars/limitChars (characters), not both.' };
1756
+ }
1757
+ if (!Number.isSafeInteger(offsetChars) || offsetChars < 0
1758
+ || !Number.isSafeInteger(limitChars) || limitChars < 1
1759
+ || input.offsetChars === null || input.limitChars === null) {
1760
+ return { success: false, isError: true, error: 'offsetChars must be a non-negative safe integer and limitChars a positive safe integer.' };
1761
+ }
1762
+ }
1744
1763
  const { mount, relativePath } = this.parsePath(input.path);
1745
1764
  const store = this.getStore();
1746
1765
 
@@ -1757,6 +1776,28 @@ export class WorkspaceModule implements Module {
1757
1776
  }
1758
1777
 
1759
1778
  const content = blob.toString('utf-8');
1779
+ if (characterPaging) {
1780
+ const start = Math.min(offsetChars, content.length);
1781
+ const splitsPair = (at: number): boolean =>
1782
+ at > 0 && at < content.length
1783
+ && content.charCodeAt(at - 1) >= 0xd800 && content.charCodeAt(at - 1) <= 0xdbff
1784
+ && content.charCodeAt(at) >= 0xdc00 && content.charCodeAt(at) <= 0xdfff;
1785
+ if (splitsPair(start)) {
1786
+ return { success: false, isError: true, error: 'offsetChars splits a surrogate pair; use nextOffsetChars from the previous page.' };
1787
+ }
1788
+ let end = start + Math.min(limitChars, content.length - start);
1789
+ if (splitsPair(end)) end++;
1790
+ return {
1791
+ success: true,
1792
+ data: {
1793
+ path: input.path,
1794
+ totalChars: content.length,
1795
+ offsetChars: start,
1796
+ nextOffsetChars: end < content.length ? end : null,
1797
+ content: content.slice(start, end),
1798
+ },
1799
+ };
1800
+ }
1760
1801
  const lines = content.split('\n');
1761
1802
 
1762
1803
  // Apply offset/limit. An unlimited read of a large file would inject the
@@ -133,6 +133,11 @@ export interface ReadInput {
133
133
  offset?: number;
134
134
  /** Number of lines to read (optional) */
135
135
  limit?: number;
136
+ /** Zero-based UTF-16 offset; selects character paging. Cannot mix with line offset/limit. */
137
+ offsetChars?: number;
138
+ /** Character page size in UTF-16 code units (default 2000); selects character paging.
139
+ * May include one extra code unit to keep a surrogate pair intact. */
140
+ limitChars?: number;
136
141
  }
137
142
 
138
143
  export interface ReadImageInput {
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Operator action log — a durable, append-only JSONL record of every
3
+ * operator-initiated mutation of a live resident: branch rollbacks, message
4
+ * suppressions, undo/redo/hide, unstick/nudge, runtime-settings changes,
5
+ * host quiesce/resume. One file per store, next to the chronicle
6
+ * (`<storePath>/operator-actions.jsonl`), so it travels with backups and
7
+ * survives host restarts the way `repair-receipts.jsonl` and
8
+ * `watchdog-wedge.jsonl` do.
9
+ *
10
+ * The chronicle record log is the authoritative history of *what* changed;
11
+ * this file records *who asked for it, from where, and why* — the part the
12
+ * store cannot know. Writes are best-effort (a logging failure never fails
13
+ * the action) and synchronous (`write(2)`, not `fsync`): an entry survives a
14
+ * process crash once the call returns, not a power loss. Mutations record
15
+ * after they complete, so a crash between the chronicle switch and the
16
+ * append loses the who/why for that one action — the branch itself remains
17
+ * the evidence. The file is created 0600: it carries requester identity and
18
+ * free-text operator notes.
19
+ */
20
+
21
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
22
+ import { dirname, join } from 'node:path';
23
+
24
+ /** Who asked. `via` names the surface ('webui', 'host-command', 'api',
25
+ * 'cli', …); `name`/`id` are whatever identity that surface has. */
26
+ export interface OperatorRequester {
27
+ via: string;
28
+ name?: string;
29
+ id?: string;
30
+ }
31
+
32
+ export interface OperatorLogEntry {
33
+ /** ISO-8601 wall-clock time of the record. */
34
+ at: string;
35
+ /** Action kind, e.g. 'rollback', 'suppress', 'undo', 'hide', 'quiesce'. */
36
+ kind: string;
37
+ /** Agent the action targeted (absent for host-wide actions). */
38
+ agent?: string;
39
+ requester?: OperatorRequester;
40
+ /** Free-text reason supplied by the operator. */
41
+ note?: string;
42
+ /** Inputs as the caller supplied them (ids, counts, patches). */
43
+ params?: Record<string, unknown>;
44
+ /** Outcome details (branches, removed ids, refs) when the action succeeded. */
45
+ result?: Record<string, unknown>;
46
+ /** Error message when the action was refused or failed. */
47
+ error?: string;
48
+ }
49
+
50
+ export type OperatorLogInput = Omit<OperatorLogEntry, 'at'> & { at?: string };
51
+
52
+ export class OperatorLog {
53
+ private warned = false;
54
+
55
+ constructor(readonly path: string | undefined) {}
56
+
57
+ get enabled(): boolean {
58
+ return this.path !== undefined;
59
+ }
60
+
61
+ /** Append one record. Never throws. Returns the stamped entry. */
62
+ append(input: OperatorLogInput): OperatorLogEntry {
63
+ const entry: OperatorLogEntry = { ...input, at: input.at ?? new Date().toISOString() };
64
+ if (!this.path) return entry;
65
+ try {
66
+ const dir = dirname(this.path);
67
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
68
+ appendFileSync(this.path, JSON.stringify(entry) + '\n', { mode: 0o600 });
69
+ } catch (error) {
70
+ if (!this.warned) {
71
+ this.warned = true;
72
+ console.error(
73
+ `[operator-log] failed to write ${this.path}: ${error instanceof Error ? error.message : String(error)}`,
74
+ );
75
+ }
76
+ }
77
+ return entry;
78
+ }
79
+
80
+ /**
81
+ * Read the newest `limit` entries (oldest first within the slice). Reads
82
+ * the whole file — the log is small (one line per operator action).
83
+ * Malformed lines are skipped rather than failing the read.
84
+ */
85
+ readTail(limit = 100): OperatorLogEntry[] {
86
+ if (!this.path || !existsSync(this.path)) return [];
87
+ let raw: string;
88
+ try {
89
+ raw = readFileSync(this.path, 'utf8');
90
+ } catch {
91
+ return [];
92
+ }
93
+ const lines = raw.split('\n').filter((l) => l.length > 0);
94
+ const slice = lines.slice(Math.max(0, lines.length - Math.max(0, limit)));
95
+ const out: OperatorLogEntry[] = [];
96
+ for (const line of slice) {
97
+ try {
98
+ const parsed = JSON.parse(line) as OperatorLogEntry;
99
+ if (parsed && typeof parsed === 'object' && typeof parsed.kind === 'string') out.push(parsed);
100
+ } catch {
101
+ // skip torn/corrupt line
102
+ }
103
+ }
104
+ return out;
105
+ }
106
+ }
107
+
108
+ export function defaultOperatorLogPath(storePath: string): string {
109
+ return join(storePath, 'operator-actions.jsonl');
110
+ }
111
+
112
+ /** Ids lists in log records are capped so one wide `hide`/`suppress` cannot
113
+ * write a multi-KB line; the full count is always recorded alongside. */
114
+ export const OPERATOR_LOG_ID_CAP = 50;
115
+ export function capIds(ids: string[]): { ids: string[]; count: number; truncated?: true } {
116
+ return ids.length > OPERATOR_LOG_ID_CAP
117
+ ? { ids: ids.slice(0, OPERATOR_LOG_ID_CAP), count: ids.length, truncated: true }
118
+ : { ids, count: ids.length };
119
+ }
120
+
121
+ /** Thrown by live surgery methods when a request cannot be honored. `code`
122
+ * lets surfaces distinguish "agent busy — quiesce first" from bad input. */
123
+ export class OperatorActionError extends Error {
124
+ constructor(
125
+ readonly code: 'unknown-agent' | 'agent-busy' | 'unknown-message' | 'invalid' | 'failed',
126
+ message: string,
127
+ options?: { cause?: unknown },
128
+ ) {
129
+ super(message, options);
130
+ this.name = 'OperatorActionError';
131
+ }
132
+ }
@@ -158,6 +158,25 @@ export class DiscordAwarenessOutbox {
158
158
  this.write(document);
159
159
  }
160
160
 
161
+ /**
162
+ * Retire a batch that was prepared but whose operation did not complete
163
+ * (fork failed, a removal threw and the source branch was restored). A
164
+ * prepared batch is armed: it re-fires at boot or on branch switch through
165
+ * `activatePreparedForBranch` / `preparedSuppressionsForBranch`, and an
166
+ * un-completable suppression there aborts framework start. Only prepared
167
+ * batches can be discarded — an active batch owns delivered markers and
168
+ * must be reconciled, not dropped. Returns false when nothing was removed.
169
+ */
170
+ discard(batchId: string): boolean {
171
+ const document = this.read();
172
+ const index = document.batches.findIndex((candidate) => candidate.id === batchId);
173
+ if (index < 0) return false;
174
+ if (document.batches[index].status !== 'prepared') return false;
175
+ document.batches.splice(index, 1);
176
+ this.write(document);
177
+ return true;
178
+ }
179
+
161
180
  /**
162
181
  * Reconcile every retained ledger entry against the active branch. A marker
163
182
  * is desired when any active batch for the same ref+emoji has a target branch
@@ -95,9 +95,36 @@ export interface FrameworkConfig {
95
95
  */
96
96
  discordAwarenessOutboxPath?: string;
97
97
 
98
+ /**
99
+ * Branch-INDEPENDENT record of the host's quiesce state (issue #122).
100
+ * Defaults to `<storePath>/recovery/host-mode.json`. Lives outside the
101
+ * chronicle because a historical rollback (branchAt + switchBranch) would
102
+ * otherwise move the active branch to a version of `framework/state`
103
+ * predating the marker, and a restart would boot SERVING mid-surgery. Set
104
+ * explicitly when providing an app-owned `store` without `storePath`;
105
+ * without either, the branch-local slot is used with a one-time warning.
106
+ */
107
+ hostModePath?: string;
108
+
109
+ /**
110
+ * Branch-independent durable queue of context writes deferred while
111
+ * quiesced. Defaults to `<storePath>/recovery/deferred-writes.json`; same
112
+ * rationale and fallback as `hostModePath`.
113
+ */
114
+ deferredWritesPath?: string;
115
+
98
116
  /** Emoji used for branch-awareness markers (default: 💤). */
99
117
  discordAwarenessEmoji?: string;
100
118
 
119
+ /**
120
+ * Durable JSONL record of operator-initiated mutations (rollback, suppress,
121
+ * undo/hide, settings, quiesce). Defaults to
122
+ * `<storePath>/operator-actions.jsonl`; set explicitly when providing an
123
+ * app-owned `store` without `storePath`. `false` disables the file (actions
124
+ * still emit `operator:action` traces).
125
+ */
126
+ operatorLogPath?: string | false;
127
+
101
128
  /**
102
129
  * Mandatory deadline for each Discord awareness marker tools/call. This is
103
130
  * independent of MCPL requestTimeoutMs and cannot be disabled with 0.
@@ -266,6 +266,20 @@ export type TraceEvent =
266
266
  toBranch: string;
267
267
  })
268
268
 
269
+ // Operator-initiated mutation (rollback/suppress/undo/hide/settings/quiesce…).
270
+ // Mirrors the durable operator-actions.jsonl record so live UIs see the
271
+ // same entry the file keeps.
272
+ | (TraceEventBase & {
273
+ type: 'operator:action';
274
+ agentName?: string;
275
+ kind: string;
276
+ requester?: { via: string; name?: string; id?: string };
277
+ note?: string;
278
+ params?: Record<string, unknown>;
279
+ result?: Record<string, unknown>;
280
+ error?: string;
281
+ })
282
+
269
283
  // MCPL subprocess stderr (one trace per line, for receipts when things break)
270
284
  | (TraceEventBase & {
271
285
  type: 'mcpl:server-stderr';
@@ -351,6 +365,10 @@ export type TraceEvent =
351
365
  toolUseId: string;
352
366
  isError: boolean;
353
367
  durationMs: number;
368
+ /** The pair was queued behind a turn that replaced the puppet's
369
+ * reservation (provider-admission re-entry) instead of stored
370
+ * directly; it lands at that turn's end flush. */
371
+ deferred?: boolean;
354
372
  })
355
373
 
356
374
  // MCPL server connection lifecycle (spawn / handshake / reconnect health).
@@ -422,6 +440,34 @@ export type TraceEvent =
422
440
  message: string;
423
441
  /** Kind-specific structured payload (mirrors the failures.log record). */
424
442
  data?: Record<string, unknown>;
443
+ })
444
+
445
+ // Host quiesce/maintenance mode (issue #122)
446
+ | (TraceEventBase & {
447
+ type: 'host:quiesce';
448
+ reason?: string;
449
+ /** Whether all turns settled within the drain window. */
450
+ drained: boolean;
451
+ /** Turns still alive at return (0 unless the drain timed out). */
452
+ activeTurns: number;
453
+ /** Wakes still parked on provider admission at return (see HostModeStatus). */
454
+ parkedAdmissions?: number;
455
+ /** True when undrained turns were force-cancelled (abandon). */
456
+ abandoned?: boolean;
457
+ /** Agents whose turn abandon could not cancel (token held, no stream). */
458
+ unabandonable?: string[];
459
+ })
460
+ | (TraceEventBase & {
461
+ type: 'host:resume';
462
+ /** True when a failing feasibility verdict was overridden. */
463
+ forced?: boolean;
464
+ /** Gated inference requests released back to the scheduler. */
465
+ releasedRequests: number;
466
+ })
467
+ | (TraceEventBase & {
468
+ type: 'host:quiesced_boot';
469
+ reason?: string;
470
+ since?: number;
425
471
  });
426
472
 
427
473
  /**