@animalabs/agent-framework 0.9.0 → 0.11.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 (109) hide show
  1. package/dist/src/agent.d.ts +1 -1
  2. package/dist/src/agent.d.ts.map +1 -1
  3. package/dist/src/agent.js.map +1 -1
  4. package/dist/src/framework.d.ts +72 -4
  5. package/dist/src/framework.d.ts.map +1 -1
  6. package/dist/src/framework.js +699 -48
  7. package/dist/src/framework.js.map +1 -1
  8. package/dist/src/gate/event-gate.d.ts +62 -7
  9. package/dist/src/gate/event-gate.d.ts.map +1 -1
  10. package/dist/src/gate/event-gate.js +333 -22
  11. package/dist/src/gate/event-gate.js.map +1 -1
  12. package/dist/src/gate/types.d.ts +56 -0
  13. package/dist/src/gate/types.d.ts.map +1 -1
  14. package/dist/src/mcpl/prose-grammar.d.ts +11 -0
  15. package/dist/src/mcpl/prose-grammar.d.ts.map +1 -1
  16. package/dist/src/mcpl/prose-grammar.js +21 -0
  17. package/dist/src/mcpl/prose-grammar.js.map +1 -1
  18. package/dist/src/mcpl/prose-stream-router.d.ts +2 -2
  19. package/dist/src/mcpl/prose-stream-router.d.ts.map +1 -1
  20. package/dist/src/mcpl/prose-stream-router.js +57 -13
  21. package/dist/src/mcpl/prose-stream-router.js.map +1 -1
  22. package/dist/src/modules/discord/index.d.ts.map +1 -1
  23. package/dist/src/modules/discord/index.js +21 -5
  24. package/dist/src/modules/discord/index.js.map +1 -1
  25. package/dist/src/modules/workspace/index.d.ts +23 -0
  26. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  27. package/dist/src/modules/workspace/index.js +53 -3
  28. package/dist/src/modules/workspace/index.js.map +1 -1
  29. package/dist/src/modules/workspace/sync.d.ts +11 -2
  30. package/dist/src/modules/workspace/sync.d.ts.map +1 -1
  31. package/dist/src/modules/workspace/sync.js +27 -11
  32. package/dist/src/modules/workspace/sync.js.map +1 -1
  33. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  34. package/dist/src/modules/workspace/watcher.js +9 -6
  35. package/dist/src/modules/workspace/watcher.js.map +1 -1
  36. package/dist/src/queue.d.ts.map +1 -1
  37. package/dist/src/queue.js +9 -2
  38. package/dist/src/queue.js.map +1 -1
  39. package/dist/src/tool-result-history.d.ts +12 -2
  40. package/dist/src/tool-result-history.d.ts.map +1 -1
  41. package/dist/src/tool-result-history.js +12 -2
  42. package/dist/src/tool-result-history.js.map +1 -1
  43. package/dist/src/types/agent.d.ts +6 -1
  44. package/dist/src/types/agent.d.ts.map +1 -1
  45. package/dist/src/types/framework.d.ts +1 -1
  46. package/dist/src/types/trace.d.ts +7 -0
  47. package/dist/src/types/trace.d.ts.map +1 -1
  48. package/dist/test/busy-agent-scheduler.test.js +29 -0
  49. package/dist/test/busy-agent-scheduler.test.js.map +1 -1
  50. package/dist/test/discord-history-delete-window.test.d.ts +2 -0
  51. package/dist/test/discord-history-delete-window.test.d.ts.map +1 -0
  52. package/dist/test/discord-history-delete-window.test.js +81 -0
  53. package/dist/test/discord-history-delete-window.test.js.map +1 -0
  54. package/dist/test/discord-history-sync.test.d.ts +2 -0
  55. package/dist/test/discord-history-sync.test.d.ts.map +1 -0
  56. package/dist/test/discord-history-sync.test.js +56 -0
  57. package/dist/test/discord-history-sync.test.js.map +1 -0
  58. package/dist/test/event-gate-passthrough.test.d.ts +15 -0
  59. package/dist/test/event-gate-passthrough.test.d.ts.map +1 -0
  60. package/dist/test/event-gate-passthrough.test.js +289 -0
  61. package/dist/test/event-gate-passthrough.test.js.map +1 -0
  62. package/dist/test/explicit-prose-routing.test.js +179 -7
  63. package/dist/test/explicit-prose-routing.test.js.map +1 -1
  64. package/dist/test/framework.test.js +33 -0
  65. package/dist/test/framework.test.js.map +1 -1
  66. package/dist/test/prose-stream-router.test.js +43 -0
  67. package/dist/test/prose-stream-router.test.js.map +1 -1
  68. package/dist/test/provider-acceleration-cooldown.test.d.ts +2 -0
  69. package/dist/test/provider-acceleration-cooldown.test.d.ts.map +1 -0
  70. package/dist/test/provider-acceleration-cooldown.test.js +286 -0
  71. package/dist/test/provider-acceleration-cooldown.test.js.map +1 -0
  72. package/dist/test/puppet-tool-call.test.d.ts +2 -0
  73. package/dist/test/puppet-tool-call.test.d.ts.map +1 -0
  74. package/dist/test/puppet-tool-call.test.js +106 -0
  75. package/dist/test/puppet-tool-call.test.js.map +1 -0
  76. package/dist/test/release-changelog.test.d.ts +2 -0
  77. package/dist/test/release-changelog.test.d.ts.map +1 -0
  78. package/dist/test/release-changelog.test.js +159 -0
  79. package/dist/test/release-changelog.test.js.map +1 -0
  80. package/dist/test/tool-result-spill.test.d.ts +1 -1
  81. package/dist/test/tool-result-spill.test.js +18 -9
  82. package/dist/test/tool-result-spill.test.js.map +1 -1
  83. package/dist/test/workspace-path-containment.test.d.ts +19 -0
  84. package/dist/test/workspace-path-containment.test.d.ts.map +1 -0
  85. package/dist/test/workspace-path-containment.test.js +91 -0
  86. package/dist/test/workspace-path-containment.test.js.map +1 -0
  87. package/dist/test/workspace-watcher.test.js +15 -3
  88. package/dist/test/workspace-watcher.test.js.map +1 -1
  89. package/dist/test/xml-round-persist.test.d.ts +19 -0
  90. package/dist/test/xml-round-persist.test.d.ts.map +1 -0
  91. package/dist/test/xml-round-persist.test.js +235 -0
  92. package/dist/test/xml-round-persist.test.js.map +1 -0
  93. package/dist/tsconfig.tsbuildinfo +1 -1
  94. package/package.json +2 -1
  95. package/src/agent.ts +1 -1
  96. package/src/framework.ts +733 -48
  97. package/src/gate/event-gate.ts +355 -24
  98. package/src/gate/types.ts +58 -0
  99. package/src/mcpl/prose-grammar.ts +21 -0
  100. package/src/mcpl/prose-stream-router.ts +48 -14
  101. package/src/modules/discord/index.ts +24 -6
  102. package/src/modules/workspace/index.ts +53 -3
  103. package/src/modules/workspace/sync.ts +39 -10
  104. package/src/modules/workspace/watcher.ts +9 -6
  105. package/src/queue.ts +8 -2
  106. package/src/tool-result-history.ts +12 -2
  107. package/src/types/agent.ts +6 -1
  108. package/src/types/framework.ts +1 -1
  109. package/src/types/trace.ts +13 -0
@@ -30,7 +30,7 @@ export interface RoutedDelta {
30
30
  delta: string;
31
31
  }
32
32
 
33
- type State = 'line-start' | 'maybe-prefix' | 'prefix-token' | 'after-token' | 'body';
33
+ type State = 'line-start' | 'hybrid-indent' | 'maybe-prefix' | 'prefix-gap' | 'prefix-token' | 'after-token' | 'body';
34
34
 
35
35
  export class ProseStreamRouter {
36
36
  private target: string | null;
@@ -42,8 +42,8 @@ export class ProseStreamRouter {
42
42
 
43
43
  constructor(
44
44
  private opts: {
45
- /** 'explicit' parses `>>` prefixes; 'locus' is pure passthrough. */
46
- mode: 'explicit' | 'locus';
45
+ /** explicit parses `>>`; hybrid parses `>>>` while retaining locus fallback. */
46
+ mode: 'explicit' | 'hybrid' | 'locus';
47
47
  /** Initial destination: the frozen turn locus (locus mode) or null. */
48
48
  initialTarget: string | null;
49
49
  /** Resolve a `>>spec` to a channelId, or null (unresolved/ambiguous). */
@@ -87,7 +87,12 @@ export class ProseStreamRouter {
87
87
  * no body — dropped, matching delivery which would deliver empty body). */
88
88
  finish(): RoutedDelta[] {
89
89
  const out: RoutedDelta[] = [];
90
- if (this.state === 'maybe-prefix') this.emit(this.held, out); // "" | ">" | ">>"
90
+ if (this.state === 'hybrid-indent') this.emit(this.held, out);
91
+ if (this.state === 'maybe-prefix') {
92
+ const full = this.opts.mode === 'hybrid' ? '>>>' : '>>';
93
+ const arrows = this.opts.mode === 'hybrid' ? this.held.trimStart() : this.held;
94
+ if (arrows !== full) this.emit(this.held, out);
95
+ }
91
96
  // prefix-token / after-token at EOF: prefix without body — nothing to emit.
92
97
  this.held = '';
93
98
  this.state = 'line-start';
@@ -97,7 +102,7 @@ export class ProseStreamRouter {
97
102
  private step(ch: string, out: RoutedDelta[]): void {
98
103
  switch (this.state) {
99
104
  case 'body':
100
- if (ch === '\n' && this.opts.mode === 'explicit') {
105
+ if (ch === '\n' && this.opts.mode !== 'locus') {
101
106
  this.emit('\n', out);
102
107
  this.state = 'line-start';
103
108
  } else {
@@ -106,34 +111,63 @@ export class ProseStreamRouter {
106
111
  return;
107
112
 
108
113
  case 'line-start':
114
+ if (this.opts.mode === 'hybrid' && (ch === ' ' || ch === '\t')) {
115
+ this.state = 'hybrid-indent';
116
+ this.held = ch;
117
+ return;
118
+ }
109
119
  if (ch === '>') { this.state = 'maybe-prefix'; this.held = '>'; return; }
110
120
  this.state = 'body';
111
121
  this.step(ch, out);
112
122
  return;
113
123
 
114
- case 'maybe-prefix':
115
- if (this.held === '>' && ch === '>') { this.held = '>>'; return; }
116
- if (this.held === '>>') {
124
+ case 'hybrid-indent':
125
+ if (ch === ' ' || ch === '\t') { this.held += ch; return; }
126
+ if (ch === '>') { this.held += ch; this.state = 'maybe-prefix'; return; }
127
+ this.emit(this.held, out);
128
+ this.held = '';
129
+ this.state = 'body';
130
+ this.step(ch, out);
131
+ return;
132
+
133
+ case 'maybe-prefix': {
134
+ const prefix = this.opts.mode === 'hybrid' ? '>>>' : '>>';
135
+ const arrows = this.opts.mode === 'hybrid' ? this.held.trimStart() : this.held;
136
+ if (ch === '>' && arrows.length < prefix.length) {
137
+ this.held += '>';
138
+ return;
139
+ }
140
+ if (arrows === prefix) {
141
+ if (this.opts.mode === 'hybrid' && (ch === ' ' || ch === '\t')) {
142
+ this.held = '';
143
+ this.state = 'prefix-gap';
144
+ return;
145
+ }
117
146
  if (ch === '\n' || ch === ' ' || ch === '\t') {
118
- // `>> quoted arrow` / bare `>>`: body text, not a prefix.
119
- this.emit(this.held, out);
147
+ if (this.opts.mode === 'explicit') this.emit(this.held, out);
120
148
  this.held = '';
121
- this.state = 'body';
122
- this.step(ch, out);
149
+ this.state = ch === '\n' ? 'line-start' : 'body';
150
+ if (ch !== '\n') this.step(ch, out);
123
151
  return;
124
152
  }
125
- // real prefix begins
126
153
  this.state = 'prefix-token';
127
154
  this.token = ch;
128
155
  this.held = '';
129
156
  return;
130
157
  }
131
- // held === '>' and ch !== '>': plain body starting with '>'
132
158
  this.emit(this.held, out);
133
159
  this.held = '';
134
160
  this.state = 'body';
135
161
  this.step(ch, out);
136
162
  return;
163
+ }
164
+
165
+ case 'prefix-gap':
166
+ if (ch === ' ' || ch === '\t') return;
167
+ if (ch === '\n') { this.target = null; this.state = 'line-start'; return; }
168
+ this.state = 'prefix-token';
169
+ this.token = ch;
170
+ return;
137
171
 
138
172
  case 'prefix-token':
139
173
  if (ch === ' ' || ch === '\t') { this.applyTarget(); this.state = 'after-token'; this.sawBang = false; return; }
@@ -754,7 +754,7 @@ export class DiscordModule implements Module {
754
754
  });
755
755
 
756
756
  // Build a map of stored message external IDs -> internal data
757
- const storedMessageMap = new Map<string, { internalId: string; content: string }>();
757
+ const storedMessageMap = new Map<string, { internalId: string; content: string; sourceTimestamp?: number }>();
758
758
  for (const msg of storedMessages) {
759
759
  const external = msg.metadata?.external as { id?: string } | undefined;
760
760
  if (external?.id) {
@@ -766,6 +766,9 @@ export class DiscordModule implements Module {
766
766
  storedMessageMap.set(external.id, {
767
767
  internalId: msg.id,
768
768
  content: textContent,
769
+ sourceTimestamp: typeof msg.metadata?.timestamp === 'number'
770
+ ? msg.metadata.timestamp
771
+ : undefined,
769
772
  });
770
773
  }
771
774
  }
@@ -774,8 +777,10 @@ export class DiscordModule implements Module {
774
777
  let editedMessages = 0;
775
778
  let deletedMessages = 0;
776
779
 
777
- // Process Discord messages - check for new/edited
778
- for (const discordMsg of discordMessages) {
780
+ // Discord history is newest-first, while addMessage appends to the stored
781
+ // conversation. Process oldest-first so missed messages retain chronology.
782
+ const chronologicalMessages = [...discordMessages].reverse();
783
+ for (const discordMsg of chronologicalMessages) {
779
784
  const stored = storedMessageMap.get(discordMsg.id);
780
785
 
781
786
  if (!stored) {
@@ -810,10 +815,23 @@ export class DiscordModule implements Module {
810
815
  }
811
816
  }
812
817
 
813
- // Check for deletes - messages we have that Discord doesn't
814
- // Only consider messages within the scrollback window (recent ones)
818
+ // Check for deletes only inside the history we actually fetched. A short
819
+ // response covers complete available history; a full page has an older
820
+ // boundary whose tied millisecond must be preserved.
821
+ const fetchedCompleteHistory = discordMessages.length < scrollback;
822
+ const oldestFetchedTimestamp = discordMessages.length > 0
823
+ ? Math.min(...discordMessages.map((msg) => msg.timestamp.getTime()))
824
+ : undefined;
815
825
  for (const [externalId, stored] of storedMessageMap) {
816
- if (!discordMessageMap.has(externalId)) {
826
+ const isInsideFetchedWindow = fetchedCompleteHistory || (
827
+ stored.sourceTimestamp !== undefined &&
828
+ oldestFetchedTimestamp !== undefined &&
829
+ // Discord timestamps are millisecond-precise, so an older message just
830
+ // beyond the page can tie the oldest fetched timestamp. Preserve ties
831
+ // rather than guessing that an unseen boundary message was deleted.
832
+ stored.sourceTimestamp > oldestFetchedTimestamp
833
+ );
834
+ if (isInsideFetchedWindow && !discordMessageMap.has(externalId)) {
817
835
  // Message exists in our store but not in Discord - it was deleted
818
836
  // Note: This only catches deletes within the scrollback window
819
837
  this.ctx.removeMessage(stored.internalId);
@@ -1080,6 +1080,47 @@ export class WorkspaceModule implements Module {
1080
1080
  return { data: blob };
1081
1081
  }
1082
1082
 
1083
+ /**
1084
+ * Read a file's raw bytes straight from the mount's filesystem, bypassing
1085
+ * the Chronicle tree.
1086
+ *
1087
+ * For material that is *passing through* rather than being kept: an upload,
1088
+ * a render being shipped somewhere, a file handed to a service. The store
1089
+ * path (`readBinary`) is append-only, so reading through it means every
1090
+ * byte is retained forever and the mount's `maxFileSize` guard applies —
1091
+ * correct for the resident's memory, wrong for egress, where paying a
1092
+ * permanent cost to send something once is the wrong trade.
1093
+ *
1094
+ * Same mount boundary and traversal guard as every other path here: only
1095
+ * declared mounts, nothing above their root. Deliberately does NOT sync,
1096
+ * register, or hash anything — it reads and returns.
1097
+ */
1098
+ async readBinaryFromDisk(
1099
+ mountPrefixedPath: string,
1100
+ opts: { maxBytes?: number } = {},
1101
+ ): Promise<{ data: Buffer; absolutePath: string } | { error: string }> {
1102
+ let mount: MountState;
1103
+ let relativePath: string;
1104
+ try {
1105
+ ({ mount, relativePath } = this.parsePath(mountPrefixedPath));
1106
+ } catch (error) {
1107
+ return { error: error instanceof Error ? error.message : String(error) };
1108
+ }
1109
+ const absolutePath = resolve(mount.config.path, relativePath);
1110
+ try {
1111
+ const info = await stat(absolutePath);
1112
+ if (!info.isFile()) return { error: `Not a file: ${mountPrefixedPath}` };
1113
+ if (opts.maxBytes !== undefined && info.size > opts.maxBytes) {
1114
+ return { error: `File too large: ${info.size} > ${opts.maxBytes} bytes` };
1115
+ }
1116
+ return { data: await readFile(absolutePath), absolutePath };
1117
+ } catch (error) {
1118
+ const code = (error as { code?: string }).code;
1119
+ if (code === 'ENOENT') return { error: `File not found on disk: ${mountPrefixedPath}` };
1120
+ return { error: error instanceof Error ? error.message : String(error) };
1121
+ }
1122
+ }
1123
+
1083
1124
  /**
1084
1125
  * Parse a mount-prefixed path into (mountName, relativePath).
1085
1126
  */
@@ -1093,10 +1134,12 @@ export class WorkspaceModule implements Module {
1093
1134
  throw new Error(`Unknown mount: "${mountName}". Available: ${[...this.mounts.keys()].join(', ')}`);
1094
1135
  }
1095
1136
 
1096
- // Path traversal guard (CWE-22): ensure resolved path stays within mount
1137
+ // Path traversal guard (CWE-22): ensure resolved path stays within mount.
1138
+ // Containment must compare with the PLATFORM separator (isContainedPath):
1139
+ // resolve() emits backslashes on Windows, so a '/'-suffixed root never
1140
+ // prefix-matches there and every in-mount path was rejected as traversal.
1097
1141
  const resolved = resolve(mount.config.path, relativePath);
1098
- const mountRoot = mount.config.path.endsWith('/') ? mount.config.path : mount.config.path + '/';
1099
- if (resolved !== mount.config.path && !resolved.startsWith(mountRoot)) {
1142
+ if (!isContainedPath(mount.config.path, resolved)) {
1100
1143
  throw new Error(`Path traversal detected: "${path}" resolves outside mount "${mountName}"`);
1101
1144
  }
1102
1145
 
@@ -1919,6 +1962,7 @@ export class WorkspaceModule implements Module {
1919
1962
  private async handleSync(input: SyncInput): Promise<ToolResult> {
1920
1963
  const store = this.getStore();
1921
1964
  const allResults: Array<{ mount: string; synced: string[]; conflicts: ConflictInfo[] }> = [];
1965
+ const allSkipped: Array<{ mount: string; path: string; reason: string }> = [];
1922
1966
 
1923
1967
  let mountsToSync: Array<{ name: string; mount: MountState }>;
1924
1968
  if (input.mount) {
@@ -1951,6 +1995,11 @@ export class WorkspaceModule implements Module {
1951
1995
  });
1952
1996
  this.emitFsEvents(name, result.synced, result.conflicts);
1953
1997
  }
1998
+ // Skips were previously computed and dropped, so "nothing synced" and
1999
+ // "your file was refused" looked identical from the outside. Say which.
2000
+ for (const s of result.skipped) {
2001
+ allSkipped.push({ mount: name, path: s.path, reason: s.reason });
2002
+ }
1954
2003
  }
1955
2004
 
1956
2005
  return {
@@ -1959,6 +2008,7 @@ export class WorkspaceModule implements Module {
1959
2008
  results: allResults,
1960
2009
  totalSynced: allResults.reduce((sum, r) => sum + r.synced.length, 0),
1961
2010
  totalConflicts: allResults.reduce((sum, r) => sum + r.conflicts.length, 0),
2011
+ ...(allSkipped.length > 0 ? { skipped: allSkipped } : {}),
1962
2012
  },
1963
2013
  };
1964
2014
  }
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { createHash } from 'node:crypto';
10
10
  import { readFile, writeFile, mkdir, readdir, stat, access } from 'node:fs/promises';
11
- import { join, dirname, relative, resolve } from 'node:path';
11
+ import { join, dirname, relative, resolve, sep } from 'node:path';
12
12
  import type { JsStore, JsTreeEntry } from '@animalabs/chronicle';
13
13
  import type { MountState } from './types.js';
14
14
 
@@ -19,8 +19,11 @@ export const DEFAULT_MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
19
19
  * Returns the absolute path, or null if the path is outside the mount.
20
20
  */
21
21
  function safePath(mountPath: string, relativePath: string): string | null {
22
+ // Compare with the PLATFORM separator: resolve() emits backslashes on
23
+ // Windows, so a '/'-suffixed root never prefix-matches there and every
24
+ // in-mount path was reported as outside the mount.
22
25
  const resolved = resolve(mountPath, relativePath);
23
- const root = mountPath.endsWith('/') ? mountPath : mountPath + '/';
26
+ const root = mountPath.endsWith(sep) ? mountPath : mountPath + sep;
24
27
  if (resolved !== mountPath && !resolved.startsWith(root)) {
25
28
  return null;
26
29
  }
@@ -40,6 +43,13 @@ export function hashContent(content: string | Buffer): string {
40
43
  /**
41
44
  * Check if content appears to be binary.
42
45
  */
46
+ export interface SkippedFile {
47
+ /** Relative path of the file that was not synced */
48
+ path: string;
49
+ /** Why, in words the resident can act on */
50
+ reason: string;
51
+ }
52
+
43
53
  export function isBinary(buffer: Buffer): boolean {
44
54
  // Check for null bytes in first 8KB
45
55
  const check = buffer.subarray(0, 8192);
@@ -68,8 +78,11 @@ export interface SyncResult {
68
78
  synced: SyncedPath[];
69
79
  /** Paths that conflicted (both agent and user modified) — filesystem wins */
70
80
  conflicts: ConflictInfo[];
71
- /** Paths that were skipped (binary, too large, etc.) */
72
- skipped: string[];
81
+ /** Paths that were skipped, each with the reason — a silently skipped file
82
+ * is indistinguishable from one that synced cleanly, which is how a
83
+ * resident ends up believing a file is in their workspace when it never
84
+ * arrived. */
85
+ skipped: SkippedFile[];
73
86
  }
74
87
 
75
88
  /**
@@ -93,7 +106,7 @@ export async function syncFromFs(
93
106
  for (const relativePath of filesToSync) {
94
107
  const absolutePath = safePath(mount.config.path, relativePath);
95
108
  if (!absolutePath) {
96
- result.skipped.push(relativePath);
109
+ result.skipped.push({ path: relativePath, reason: 'path resolves outside the mount' });
97
110
  continue;
98
111
  }
99
112
 
@@ -113,13 +126,23 @@ export async function syncFromFs(
113
126
  const fileStat = await stat(absolutePath);
114
127
  if (!fileStat.isFile()) continue;
115
128
  if (fileStat.size > maxSize) {
116
- result.skipped.push(relativePath);
129
+ result.skipped.push({
130
+ path: relativePath,
131
+ reason: `too large: ${fileStat.size} bytes > maxFileSize ${maxSize}`,
132
+ });
117
133
  continue;
118
134
  }
119
135
 
120
136
  const buffer = await readFile(absolutePath);
121
137
  if (isBinary(buffer)) {
122
- result.skipped.push(relativePath);
138
+ // Disk -> store sync is text-only, at ANY size: the tree stores text
139
+ // blobs. Binary that must live in the store has to be written through
140
+ // writeBinary; binary that is only passing through should be read with
141
+ // readBinaryFromDisk instead of synced at all.
142
+ result.skipped.push({
143
+ path: relativePath,
144
+ reason: 'binary file — disk sync stores text only (read it from disk, or write it via a binary-aware tool)',
145
+ });
123
146
  continue;
124
147
  }
125
148
 
@@ -159,8 +182,11 @@ export async function syncFromFs(
159
182
 
160
183
  store.treeSet(mount.treeStateId, relativePath, entry);
161
184
  result.synced.push({ path: relativePath, op: existing ? 'modified' : 'created' });
162
- } catch {
163
- result.skipped.push(relativePath);
185
+ } catch (error) {
186
+ result.skipped.push({
187
+ path: relativePath,
188
+ reason: error instanceof Error ? error.message : String(error),
189
+ });
164
190
  }
165
191
  }
166
192
 
@@ -276,7 +302,10 @@ async function walkDirectory(
276
302
  if (results.length >= maxFiles) break;
277
303
 
278
304
  const fullPath = join(dir, entry.name);
279
- const relativePath = relative(basePath, fullPath);
305
+ // Logical workspace paths are '/'-separated everywhere (mount-prefixed
306
+ // paths, chronicle tree paths); node's relative() emits backslashes on
307
+ // Windows, so normalize. No-op on POSIX (sep === '/').
308
+ const relativePath = relative(basePath, fullPath).split(sep).join('/');
280
309
 
281
310
  // Check ignore patterns (simple glob matching)
282
311
  if (shouldIgnore(relativePath, entry.name, ignorePatterns)) continue;
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { watch, type FSWatcher } from 'chokidar';
9
9
  import { mkdirSync, statSync } from 'node:fs';
10
- import { basename } from 'node:path';
10
+ import { basename, sep } from 'node:path';
11
11
  import { type MountConfig } from './types.js';
12
12
 
13
13
  export type FsOp = 'created' | 'modified' | 'deleted';
@@ -363,20 +363,23 @@ export class MountWatcher {
363
363
  }
364
364
 
365
365
  private toRelative(absolutePath: string): string | null {
366
- const base = this.config.path.endsWith('/')
366
+ // Chokidar hands back platform-separated absolute paths; compare with
367
+ // the platform separator and normalize the result to the '/'-separated
368
+ // logical form the rest of the workspace uses. No-op on POSIX.
369
+ const base = this.config.path.endsWith(sep)
367
370
  ? this.config.path
368
- : this.config.path + '/';
371
+ : this.config.path + sep;
369
372
  if (absolutePath.startsWith(base)) {
370
- return absolutePath.slice(base.length);
373
+ return absolutePath.slice(base.length).split(sep).join('/');
371
374
  }
372
375
  return null;
373
376
  }
374
377
 
375
378
  private isRootPath(absolutePath: string): boolean {
376
- const root = this.config.path.endsWith('/')
379
+ const root = this.config.path.endsWith(sep)
377
380
  ? this.config.path.slice(0, -1)
378
381
  : this.config.path;
379
- const candidate = absolutePath.endsWith('/')
382
+ const candidate = absolutePath.endsWith(sep)
380
383
  ? absolutePath.slice(0, -1)
381
384
  : absolutePath;
382
385
  return candidate === root;
package/src/queue.ts CHANGED
@@ -22,8 +22,14 @@ export class ProcessQueueImpl implements ProcessQueue {
22
22
  if (waiter) {
23
23
  waiter(event);
24
24
  } else if (event.type === 'external-message') {
25
- // User input jumps to front so it's processed before queued internal events
26
- this.queue.unshift(event);
25
+ // User input stays ahead of queued internal work, but joins the end of
26
+ // the external-message lane so rapid messages retain arrival order.
27
+ const firstInternal = this.queue.findIndex((queued) => queued.type !== 'external-message');
28
+ if (firstInternal === -1) {
29
+ this.queue.push(event);
30
+ } else {
31
+ this.queue.splice(firstInternal, 0, event);
32
+ }
27
33
  } else {
28
34
  this.queue.push(event);
29
35
  }
@@ -20,10 +20,20 @@ import { safeSlice } from './safe-slice.js';
20
20
  * House-safe default inline cap (chars) for tool results, error results, and
21
21
  * background-script wake payloads (issue #89: a resident should never eat a
22
22
  * 42k blob they didn't ask for). Durable per-residence value comes from
23
- * `FrameworkConfig.toolResultInlineMaxChars`; a temporary per-agent lift from
23
+ * `FrameworkConfig.toolResultInlineMaxChars`; a per-agent value from
24
24
  * agent_settings `tool_result_inline_max_chars`.
25
+ *
26
+ * Raised 5000 → 24000 (2026-08-07) on production evidence rather than taste.
27
+ * The first residence to actually run the 5000 default spilled 48 results in
28
+ * 23h, 71% of them ordinary shell output from source review — `git diff`, test
29
+ * runs, `sed -n '450,590p'`-style excerpts, which routinely land at 5–15k. A
30
+ * 5000-char cap is tuned for conversation and turns every file read into a
31
+ * spill round-trip for a resident doing code work. Measured on that corpus:
32
+ * 20000 keeps 76% inline, 24000 keeps 88%, 32000 only reaches 90% — 24000 is
33
+ * the elbow. Genuinely large payloads (full histories, channel registries,
34
+ * broad greps) still spill, which is the point.
25
35
  */
26
- export const DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS = 5000;
36
+ export const DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS = 24000;
27
37
 
28
38
  export function toolResultDataToHistoryString(data: unknown, maxChars?: number): string {
29
39
  const fromArray = tryHistoryStringFromContentArray(data);
@@ -177,8 +177,13 @@ export interface AgentConfig {
177
177
  * (`>>#channel` / `>>@person` / `>>skip_reply`); unprefixed prose is never
178
178
  * delivered — it bounces to a clipboard for a cheap prefixed resend.
179
179
  * See docs/explicit-prose-routing.md.
180
+ * - 'hybrid': unprefixed prose keeps locus routing; `>>>destination` routes
181
+ * that publication envelope through the same authorized resolver.
182
+ * See docs/hybrid-prose-routing.md.
183
+ * - 'disabled': generated prose is never published. Only explicit tools may
184
+ * send externally; prose remains in Chronicle with a private suppression receipt.
180
185
  */
181
- proseRouting?: 'locus' | 'explicit';
186
+ proseRouting?: 'locus' | 'explicit' | 'hybrid' | 'disabled';
182
187
  }
183
188
 
184
189
  /** The intentionally small set of agent settings that may change in-process. */
@@ -145,7 +145,7 @@ export interface FrameworkConfig {
145
145
  * name, overwritten on collision, retained until the workspace owner
146
146
  * deletes it — never auto-GC'd) and replaced inline by a bounded preview
147
147
  * plus the file reference; with no writable workspace the fallback is
148
- * explicit plain truncation. Default 5000 (house-safe; issue #89). Must be
148
+ * explicit plain truncation. Default 24000 (house-safe; issue #89). Must be
149
149
  * >= 1000. A resident's own agent_settings value
150
150
  * `tool_result_inline_max_chars` (durable, persisted in framework state)
151
151
  * takes precedence over this for that agent; the EFFECTIVE cap for every
@@ -340,6 +340,19 @@ export type TraceEvent =
340
340
  textLen: number;
341
341
  })
342
342
 
343
+ // Admin puppet: an operator executed a tool AS an agent and stored the
344
+ // tool_use + tool_result pair in that agent's window (Framework.
345
+ // puppetToolCall). Provenance lives HERE and in the host log — the stored
346
+ // messages deliberately byte-match a model-initiated call.
347
+ | (TraceEventBase & {
348
+ type: 'puppet:tool-call';
349
+ agentName: string;
350
+ toolName: string;
351
+ toolUseId: string;
352
+ isError: boolean;
353
+ durationMs: number;
354
+ })
355
+
343
356
  // MCPL server connection lifecycle (spawn / handshake / reconnect health).
344
357
  // Previously these outcomes were visible only on the host process's own
345
358
  // stderr, so a server that lost the boot handshake race just went missing.