@songsid/agend 2.2.0-alpha.2 → 2.2.0-alpha.4

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 (257) hide show
  1. package/README.md +4 -4
  2. package/README.zh-TW.md +4 -4
  3. package/dist/agent-endpoint.d.ts +4 -1
  4. package/dist/agent-endpoint.js +14 -2
  5. package/dist/agent-endpoint.js.map +1 -1
  6. package/dist/atomic-write.d.ts +21 -0
  7. package/dist/atomic-write.js +65 -0
  8. package/dist/atomic-write.js.map +1 -0
  9. package/dist/auth-api.js +2 -0
  10. package/dist/auth-api.js.map +1 -1
  11. package/dist/backend/codex.d.ts +7 -0
  12. package/dist/backend/codex.js +7 -0
  13. package/dist/backend/codex.js.map +1 -1
  14. package/dist/backend/muse.d.ts +7 -0
  15. package/dist/backend/muse.js +7 -0
  16. package/dist/backend/muse.js.map +1 -1
  17. package/dist/backend/types.d.ts +6 -0
  18. package/dist/backend/types.js.map +1 -1
  19. package/dist/binary-probe.d.ts +80 -0
  20. package/dist/binary-probe.js +182 -0
  21. package/dist/binary-probe.js.map +1 -0
  22. package/dist/cache-service.d.ts +17 -1
  23. package/dist/cache-service.js +39 -4
  24. package/dist/cache-service.js.map +1 -1
  25. package/dist/channel/adapters/discord.d.ts +5 -1
  26. package/dist/channel/adapters/discord.js +56 -11
  27. package/dist/channel/adapters/discord.js.map +1 -1
  28. package/dist/channel/adapters/telegram.d.ts +38 -1
  29. package/dist/channel/adapters/telegram.js +82 -0
  30. package/dist/channel/adapters/telegram.js.map +1 -1
  31. package/dist/channel/mcp-tools.js +1 -1
  32. package/dist/channel/mcp-tools.js.map +1 -1
  33. package/dist/channel/tool-router.d.ts +8 -1
  34. package/dist/channel/tool-router.js +4 -2
  35. package/dist/channel/tool-router.js.map +1 -1
  36. package/dist/channel/types.d.ts +21 -0
  37. package/dist/cli.js +133 -38
  38. package/dist/cli.js.map +1 -1
  39. package/dist/connection-secrets.d.ts +5 -0
  40. package/dist/connection-secrets.js.map +1 -1
  41. package/dist/cpu-profile.js +50 -0
  42. package/dist/cpu-profile.js.map +1 -1
  43. package/dist/daemon.d.ts +12 -0
  44. package/dist/daemon.js +166 -26
  45. package/dist/daemon.js.map +1 -1
  46. package/dist/delivery-outbox.d.ts +1 -0
  47. package/dist/delivery-outbox.js +12 -0
  48. package/dist/delivery-outbox.js.map +1 -1
  49. package/dist/discord-general.d.ts +15 -0
  50. package/dist/discord-general.js +24 -0
  51. package/dist/discord-general.js.map +1 -0
  52. package/dist/discord-permissions.d.ts +33 -0
  53. package/dist/discord-permissions.js +42 -0
  54. package/dist/discord-permissions.js.map +1 -0
  55. package/dist/env-file.d.ts +11 -0
  56. package/dist/env-file.js +27 -0
  57. package/dist/env-file.js.map +1 -0
  58. package/dist/fleet-context.d.ts +5 -0
  59. package/dist/fleet-context.js.map +1 -1
  60. package/dist/fleet-level-config.d.ts +8 -1
  61. package/dist/fleet-level-config.js +31 -0
  62. package/dist/fleet-level-config.js.map +1 -1
  63. package/dist/fleet-manager.d.ts +76 -7
  64. package/dist/fleet-manager.js +610 -142
  65. package/dist/fleet-manager.js.map +1 -1
  66. package/dist/fleet-stop-budget.d.ts +22 -0
  67. package/dist/fleet-stop-budget.js +95 -0
  68. package/dist/fleet-stop-budget.js.map +1 -0
  69. package/dist/kiro-transcript-lane.d.ts +8 -4
  70. package/dist/kiro-transcript-lane.js +66 -44
  71. package/dist/kiro-transcript-lane.js.map +1 -1
  72. package/dist/locale.js +88 -0
  73. package/dist/locale.js.map +1 -1
  74. package/dist/next-steps.d.ts +5 -0
  75. package/dist/next-steps.js +26 -0
  76. package/dist/next-steps.js.map +1 -0
  77. package/dist/outbound-handlers.js +8 -0
  78. package/dist/outbound-handlers.js.map +1 -1
  79. package/dist/outbound-schemas.d.ts +4 -0
  80. package/dist/outbound-schemas.js +6 -1
  81. package/dist/outbound-schemas.js.map +1 -1
  82. package/dist/outbox-open-error.d.ts +7 -0
  83. package/dist/outbox-open-error.js +15 -0
  84. package/dist/outbox-open-error.js.map +1 -0
  85. package/dist/pane-context-cache.d.ts +25 -0
  86. package/dist/pane-context-cache.js +73 -0
  87. package/dist/pane-context-cache.js.map +1 -0
  88. package/dist/paths.d.ts +0 -7
  89. package/dist/paths.js +8 -2
  90. package/dist/paths.js.map +1 -1
  91. package/dist/provider-probe.d.ts +15 -0
  92. package/dist/provider-probe.js +20 -0
  93. package/dist/provider-probe.js.map +1 -1
  94. package/dist/public-link-progress.d.ts +75 -0
  95. package/dist/public-link-progress.js +204 -0
  96. package/dist/public-link-progress.js.map +1 -0
  97. package/dist/public-web-gateway.js +4 -1
  98. package/dist/public-web-gateway.js.map +1 -1
  99. package/dist/public-web-link.d.ts +9 -2
  100. package/dist/public-web-link.js +89 -15
  101. package/dist/public-web-link.js.map +1 -1
  102. package/dist/quickstart-api.d.ts +93 -20
  103. package/dist/quickstart-api.js +198 -63
  104. package/dist/quickstart-api.js.map +1 -1
  105. package/dist/quickstart.d.ts +15 -0
  106. package/dist/quickstart.js +100 -29
  107. package/dist/quickstart.js.map +1 -1
  108. package/dist/reply-buttons-controller.d.ts +98 -0
  109. package/dist/reply-buttons-controller.js +174 -0
  110. package/dist/reply-buttons-controller.js.map +1 -0
  111. package/dist/reply-buttons.d.ts +113 -0
  112. package/dist/reply-buttons.js +208 -0
  113. package/dist/reply-buttons.js.map +1 -0
  114. package/dist/reply-dedup.js +3 -1
  115. package/dist/reply-dedup.js.map +1 -1
  116. package/dist/restart-guard.d.ts +1 -0
  117. package/dist/restart-guard.js +8 -3
  118. package/dist/restart-guard.js.map +1 -1
  119. package/dist/service-activation.d.ts +15 -2
  120. package/dist/service-activation.js +81 -4
  121. package/dist/service-activation.js.map +1 -1
  122. package/dist/service-installer.js +19 -1
  123. package/dist/service-installer.js.map +1 -1
  124. package/dist/settings-api.js +53 -12
  125. package/dist/settings-api.js.map +1 -1
  126. package/dist/settings-authority.d.ts +11 -0
  127. package/dist/settings-authority.js +128 -0
  128. package/dist/settings-authority.js.map +1 -0
  129. package/dist/settings-change.d.ts +5 -0
  130. package/dist/settings-change.js +4 -3
  131. package/dist/settings-change.js.map +1 -1
  132. package/dist/settings-confirmation.d.ts +3 -0
  133. package/dist/settings-confirmation.js +7 -1
  134. package/dist/settings-confirmation.js.map +1 -1
  135. package/dist/settings-effect.js +19 -7
  136. package/dist/settings-effect.js.map +1 -1
  137. package/dist/settings-http-confirmation.js +1 -1
  138. package/dist/settings-http-confirmation.js.map +1 -1
  139. package/dist/settings-redaction.d.ts +18 -0
  140. package/dist/settings-redaction.js +58 -0
  141. package/dist/settings-redaction.js.map +1 -0
  142. package/dist/setup-form.d.ts +9 -1
  143. package/dist/setup-form.js +25 -1
  144. package/dist/setup-form.js.map +1 -1
  145. package/dist/setup-host.js +13 -3
  146. package/dist/setup-host.js.map +1 -1
  147. package/dist/setup-wizard.js +26 -19
  148. package/dist/setup-wizard.js.map +1 -1
  149. package/dist/systemd-runtime.d.ts +15 -0
  150. package/dist/systemd-runtime.js +41 -0
  151. package/dist/systemd-runtime.js.map +1 -0
  152. package/dist/tmux-control.d.ts +22 -0
  153. package/dist/tmux-control.js +82 -17
  154. package/dist/tmux-control.js.map +1 -1
  155. package/dist/token-env-name.d.ts +37 -0
  156. package/dist/token-env-name.js +93 -0
  157. package/dist/token-env-name.js.map +1 -0
  158. package/dist/tool-permissions.js +5 -3
  159. package/dist/tool-permissions.js.map +1 -1
  160. package/dist/topic-commands.d.ts +7 -16
  161. package/dist/topic-commands.js +48 -84
  162. package/dist/topic-commands.js.map +1 -1
  163. package/dist/transcript-monitor.js +3 -0
  164. package/dist/transcript-monitor.js.map +1 -1
  165. package/dist/transcript-sources.d.ts +68 -0
  166. package/dist/transcript-sources.js +336 -29
  167. package/dist/transcript-sources.js.map +1 -1
  168. package/dist/transcript-turns.d.ts +39 -0
  169. package/dist/transcript-turns.js +58 -0
  170. package/dist/transcript-turns.js.map +1 -0
  171. package/dist/tunnel/cloudflared-install.d.ts +42 -0
  172. package/dist/tunnel/cloudflared-install.js +194 -9
  173. package/dist/tunnel/cloudflared-install.js.map +1 -1
  174. package/dist/tunnel/deb-extract.d.ts +9 -0
  175. package/dist/tunnel/deb-extract.js +94 -0
  176. package/dist/tunnel/deb-extract.js.map +1 -0
  177. package/dist/turn-reply-guard.d.ts +3 -0
  178. package/dist/turn-reply-guard.js.map +1 -1
  179. package/dist/ui/app.html +3 -0
  180. package/dist/ui/chat-render.js +32 -6
  181. package/dist/ui/chat-store.js +21 -1
  182. package/dist/ui/chat-strings.js +45 -0
  183. package/dist/ui/chat-thread.js +109 -52
  184. package/dist/ui/first-run.js +64 -0
  185. package/dist/ui/html-attachment.js +26 -0
  186. package/dist/ui/icons/apple-touch-icon.png +0 -0
  187. package/dist/ui/icons/favicon.ico +0 -0
  188. package/dist/ui/icons/favicon.svg +8 -0
  189. package/dist/ui/panel-chat.js +16 -7
  190. package/dist/ui/panel-fleet.js +5 -5
  191. package/dist/ui/panel-settings.js +18 -8
  192. package/dist/ui/preview-panel.js +47 -7
  193. package/dist/ui/preview.js +36 -13
  194. package/dist/ui/settings-apply.js +3 -1
  195. package/dist/ui/settings-dialogs.js +95 -25
  196. package/dist/ui/settings-discord.js +130 -0
  197. package/dist/ui/settings-model.js +21 -0
  198. package/dist/ui/settings-strings.js +46 -6
  199. package/dist/ui/settings-token.js +54 -0
  200. package/dist/ui/settings-wizard.js +63 -29
  201. package/dist/ui/shared/app-shell.js +19 -33
  202. package/dist/ui/shared/app-store.js +5 -3
  203. package/dist/ui/shared/app-stream.js +2 -1
  204. package/dist/ui/shared/app.css +34 -2
  205. package/dist/ui/shared/app.js +2 -1
  206. package/dist/ui/shared/instance-nav.js +310 -0
  207. package/dist/ui/shared/panel-view.js +12 -190
  208. package/dist/ui/shared/view-roster-store.js +5 -0
  209. package/dist/ui/shared/view-strings.js +12 -0
  210. package/dist/ui/signin.html +3 -0
  211. package/dist/update-dispatch.d.ts +1 -1
  212. package/dist/update-dispatch.js +1 -1
  213. package/dist/update-install.d.ts +15 -3
  214. package/dist/update-install.js +41 -13
  215. package/dist/update-install.js.map +1 -1
  216. package/dist/update-launch.d.ts +22 -0
  217. package/dist/update-launch.js +106 -0
  218. package/dist/update-launch.js.map +1 -0
  219. package/dist/update-marker.js +3 -8
  220. package/dist/update-marker.js.map +1 -1
  221. package/dist/usage/format-rich.js +7 -1
  222. package/dist/usage/format-rich.js.map +1 -1
  223. package/dist/usage/i18n-keys.d.ts +1 -1
  224. package/dist/usage/i18n-keys.js +1 -1
  225. package/dist/usage/i18n-keys.js.map +1 -1
  226. package/dist/usage/providers.d.ts +4 -0
  227. package/dist/usage/providers.js +2 -1
  228. package/dist/usage/providers.js.map +1 -1
  229. package/dist/usage/usage-api.js +48 -2
  230. package/dist/usage/usage-api.js.map +1 -1
  231. package/dist/view-api.d.ts +40 -0
  232. package/dist/view-api.js +28 -10
  233. package/dist/view-api.js.map +1 -1
  234. package/dist/web-api.d.ts +8 -0
  235. package/dist/web-api.js +53 -17
  236. package/dist/web-api.js.map +1 -1
  237. package/dist/web-auth.d.ts +8 -0
  238. package/dist/web-auth.js +34 -8
  239. package/dist/web-auth.js.map +1 -1
  240. package/dist/web-chat-history.d.ts +21 -0
  241. package/dist/web-chat-history.js +44 -0
  242. package/dist/web-chat-history.js.map +1 -1
  243. package/dist/web-host-guard.d.ts +18 -2
  244. package/dist/web-host-guard.js +30 -5
  245. package/dist/web-host-guard.js.map +1 -1
  246. package/dist/web-icons.d.ts +21 -0
  247. package/dist/web-icons.js +38 -0
  248. package/dist/web-icons.js.map +1 -0
  249. package/dist/web-preview.d.ts +9 -0
  250. package/dist/web-preview.js +15 -9
  251. package/dist/web-preview.js.map +1 -1
  252. package/dist/web-session.d.ts +7 -1
  253. package/dist/web-session.js +23 -2
  254. package/dist/web-session.js.map +1 -1
  255. package/launcher/postinstall.cjs +8 -4
  256. package/package.json +1 -1
  257. package/templates/systemd.service.ejs +3 -3
@@ -1,6 +1,7 @@
1
1
  import { type RolloutIndex } from "./rollout-index.js";
2
2
  import { type KiroDbLane } from "./kiro-transcript-lane.js";
3
3
  export { extractKiroAssistantStrings } from "./kiro-db-reader.js";
4
+ import type { TranscriptTurnEvent } from "./transcript-turns.js";
4
5
  export interface ToolUseEvent {
5
6
  name: string;
6
7
  input: unknown;
@@ -11,6 +12,8 @@ export interface TranscriptEvents {
11
12
  name: string;
12
13
  }>;
13
14
  assistantTexts: string[];
15
+ /** #1510: turn boundaries, from a source that records them (codex). */
16
+ turns?: TranscriptTurnEvent[];
14
17
  }
15
18
  export interface TranscriptCheckpoint {
16
19
  path: string;
@@ -51,6 +54,8 @@ export declare class CodexRolloutSource implements TranscriptSource {
51
54
  */
52
55
  private accepted;
53
56
  private readonly index;
57
+ /** #1510: the turn the records being read belong to (its `task_started`), carried across polls of one file. */
58
+ private openTurnId;
54
59
  constructor(workingDirectory: string, sessionsDir?: string, _now?: number, index?: RolloutIndex);
55
60
  reset(): void;
56
61
  checkpoint(): Promise<TranscriptCheckpoint | null>;
@@ -70,6 +75,49 @@ export declare class CodexRolloutSource implements TranscriptSource {
70
75
  private fileBelongsToUs;
71
76
  poll(): Promise<TranscriptEvents>;
72
77
  }
78
+ /**
79
+ * #1510: muse's turn boundaries, from its session logs (`<root>/<YYYY>/<MM>/<DD>/<id>/session.jsonl`, recorded on
80
+ * 1.4.4 in tests/fixtures/reply-guard-1510). Only turns are read; muse's tool activity is not.
81
+ *
82
+ * - a run is a turn: `runtime.session` run `started` → `terminal {reason}` (null: done; "cancelled …": the user
83
+ * stopped it; anything else: it failed);
84
+ * - a message is `runtime.user_intent.accepted` (its text, surface "main") and lands in a run with
85
+ * `user_intent.materialized` — a new run, or the active one when it was typed mid-run;
86
+ * - the end-of-turn reminder observers write their own session directories and no main-surface intents.
87
+ *
88
+ * Every log whose head names this working directory is followed: ownership is decided later, by the delivered text
89
+ * (TranscriptTurnLedger), so two muse instances in one directory cannot vouch for each other. The directory tree is
90
+ * re-listed at most every `listTtlMs`; between listings only the logs already known are read, and only when they grew.
91
+ * Logs present when the source is created are read from their end (existing history is not this launch's).
92
+ */
93
+ export declare class MuseSessionSource implements TranscriptSource {
94
+ private workingDirectory;
95
+ private root;
96
+ private listTtlMs;
97
+ private now;
98
+ /**
99
+ * session.jsonl path → where to read next (cwd-matched logs only): a byte offset, or `{ anchor }` for a log that
100
+ * existed before this source (read from its last line boundary at `anchor` bytes, or at its size when first read
101
+ * if its size was not known then — never from its start).
102
+ */
103
+ private files;
104
+ /** Every log present at the baseline, with its size then (null: it was there, its size could not be read). */
105
+ private preexisting;
106
+ /** Whether the baseline listing saw the whole tree; if not, a log found later may be old and is read from its end. */
107
+ private baselineComplete;
108
+ private rejected;
109
+ private sizes;
110
+ private listedAt;
111
+ private baselined;
112
+ /** intent_id → its text and time, until it materializes (bounded). */
113
+ private intents;
114
+ constructor(workingDirectory: string, root?: string, listTtlMs?: number, now?: () => number);
115
+ reset(): void;
116
+ initialize(): Promise<void>;
117
+ private list;
118
+ poll(): Promise<TranscriptEvents>;
119
+ private parse;
120
+ }
73
121
  /**
74
122
  * The conversation database for one kiro store.
75
123
  *
@@ -112,6 +160,10 @@ includeHistory?: boolean): KiroConversationRead;
112
160
  * Kiro 2.19 moved primary conversations to conversations_v2 in data.sqlite3;
113
161
  * legacy releases use <uuid>.jsonl plus sibling <uuid>.json metadata.
114
162
  */
163
+ /** The Kiro legacy fallback's scan bounds (#1490): see KiroSessionSource.resolveActiveSession. */
164
+ export declare const KIRO_FULL_SCAN_MS = 30000;
165
+ export declare const KIRO_QUIET_MS: number;
166
+ export declare const KIRO_SCAN_CONCURRENCY = 16;
115
167
  export declare class KiroSessionSource implements TranscriptSource {
116
168
  private workingDirectory;
117
169
  private sessionsDir;
@@ -137,6 +189,22 @@ export declare class KiroSessionSource implements TranscriptSource {
137
189
  * `null` = the file does not concern us (another cwd, or a subagent child).
138
190
  */
139
191
  private metaCache;
192
+ /** The sessions directory as last listed: re-listed only when its mtime moves (a session added or removed). */
193
+ private sessionsListing;
194
+ private fullScanAt;
195
+ /** Monotonic clock for the full-scan spacing; wall clock for comparing with file times. Test hooks. */
196
+ private mono;
197
+ private wall;
198
+ /**
199
+ * The newest session of this working directory, from its metadata (#1490). It used to run `readdirSync` plus a
200
+ * `statSync` of every metadata file on the event loop, on every 2 s poll while the store had no row for us: 16–18 ms
201
+ * per instance with 5,000 sessions. Now it is asynchronous and bounded:
202
+ * - the directory is listed again only when its mtime moved, or when it was read right after a change (file
203
+ * times are coarse: a session created in the same tick would share that mtime), or on a full scan;
204
+ * - a metadata file quiet for KIRO_QUIET_MS is not stat'ed again until the next full scan, so a poll costs one
205
+ * stat of the directory plus the recently active files; an old session resumed is seen within KIRO_FULL_SCAN_MS;
206
+ * - every full scan (each KIRO_FULL_SCAN_MS) re-stats everything, KIRO_SCAN_CONCURRENCY at a time.
207
+ */
140
208
  private resolveActiveSession;
141
209
  poll(): Promise<TranscriptEvents>;
142
210
  }
@@ -26,7 +26,8 @@ import { measureSyncWork } from "./sync-work-attribution.js";
26
26
  * - Every poll re-resolves which transcript is active. Pinning to the first
27
27
  * one found silently goes blind when the CLI starts a new session.
28
28
  */
29
- import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync, realpathSync, statSync } from "node:fs";
29
+ import { closeSync, existsSync, openSync, readSync, realpathSync } from "node:fs";
30
+ import { readdir, readFile, stat } from "node:fs/promises";
30
31
  import { homedir } from "node:os";
31
32
  import { join, resolve } from "node:path";
32
33
  import { sharedRolloutIndex } from "./rollout-index.js";
@@ -34,6 +35,8 @@ import Database from "better-sqlite3";
34
35
  import { sharedKiroTranscriptLane } from "./kiro-transcript-lane.js";
35
36
  export { extractKiroAssistantStrings } from "./kiro-db-reader.js";
36
37
  import { lastLineBoundary, readNewLines } from "./transcript-jsonl.js";
38
+ import { museSessionCwd, readFileHeadSync } from "./backend/muse.js";
39
+ import { performance } from "node:perf_hooks";
37
40
  const EMPTY = { toolUses: [], toolResults: [], assistantTexts: [] };
38
41
  function emptyEvents() {
39
42
  return { toolUses: [], toolResults: [], assistantTexts: [] };
@@ -60,6 +63,8 @@ export class CodexRolloutSource {
60
63
  */
61
64
  accepted = new Set();
62
65
  index;
66
+ /** #1510: the turn the records being read belong to (its `task_started`), carried across polls of one file. */
67
+ openTurnId = "";
63
68
  constructor(workingDirectory, sessionsDir = join(process.env.CODEX_HOME?.trim() || join(homedir(), ".codex"), "sessions"), _now = Date.now(), index) {
64
69
  this.workingDirectory = workingDirectory;
65
70
  this.sessionsDir = sessionsDir;
@@ -69,6 +74,7 @@ export class CodexRolloutSource {
69
74
  reset() {
70
75
  this.currentFile = null;
71
76
  this.byteOffset = 0;
77
+ this.openTurnId = "";
72
78
  this.rejected.clear();
73
79
  this.accepted.clear();
74
80
  this.snapshotExistingFiles();
@@ -164,10 +170,12 @@ export class CodexRolloutSource {
164
170
  }
165
171
  this.currentFile = active.path;
166
172
  this.byteOffset = offset;
173
+ this.openTurnId = "";
167
174
  }
168
175
  const { lines, newOffset } = await readNewLines(this.currentFile, this.byteOffset);
169
176
  this.byteOffset = newOffset;
170
177
  const events = emptyEvents();
178
+ const turns = [];
171
179
  for (const line of lines) {
172
180
  let entry;
173
181
  try {
@@ -176,11 +184,32 @@ export class CodexRolloutSource {
176
184
  catch {
177
185
  continue;
178
186
  }
179
- if (entry.type !== "response_item")
180
- continue;
181
187
  const p = entry.payload;
182
188
  if (!p)
183
189
  continue;
190
+ // #1510: codex 0.162 writes one task_started per turn and ends it with task_complete (last_agent_message null
191
+ // after a provider error) or turn_aborted; a steer lands inside the open turn as another user message.
192
+ if (entry.type === "event_msg") {
193
+ if (p.type === "task_started" && typeof p.turn_id === "string") {
194
+ this.openTurnId = p.turn_id;
195
+ turns.push({ kind: "start", turnId: p.turn_id });
196
+ }
197
+ else if ((p.type === "task_complete" || p.type === "turn_aborted") && typeof p.turn_id === "string") {
198
+ const end = p.type === "turn_aborted" ? "aborted" : p.last_agent_message == null ? "error" : "complete";
199
+ turns.push({ kind: "end", turnId: p.turn_id, end });
200
+ }
201
+ continue;
202
+ }
203
+ if (entry.type !== "response_item")
204
+ continue;
205
+ if (p.type === "message" && p.role === "user") {
206
+ const text = (p.content ?? [])
207
+ .map(block => typeof block.text === "string" ? block.text : "").join("\n");
208
+ const at = Date.parse(String(entry.timestamp ?? ""));
209
+ if (text.trim() && Number.isFinite(at))
210
+ turns.push({ kind: "user", turnId: this.openTurnId, text, at });
211
+ continue;
212
+ }
184
213
  if (p.type === "function_call") {
185
214
  let input = p.arguments;
186
215
  if (typeof input === "string") {
@@ -210,8 +239,230 @@ export class CodexRolloutSource {
210
239
  }
211
240
  }
212
241
  }
242
+ if (turns.length)
243
+ events.turns = turns;
244
+ return events;
245
+ }
246
+ }
247
+ /* -------------------------------------------------------------------- muse */
248
+ /**
249
+ * #1510: muse's turn boundaries, from its session logs (`<root>/<YYYY>/<MM>/<DD>/<id>/session.jsonl`, recorded on
250
+ * 1.4.4 in tests/fixtures/reply-guard-1510). Only turns are read; muse's tool activity is not.
251
+ *
252
+ * - a run is a turn: `runtime.session` run `started` → `terminal {reason}` (null: done; "cancelled …": the user
253
+ * stopped it; anything else: it failed);
254
+ * - a message is `runtime.user_intent.accepted` (its text, surface "main") and lands in a run with
255
+ * `user_intent.materialized` — a new run, or the active one when it was typed mid-run;
256
+ * - the end-of-turn reminder observers write their own session directories and no main-surface intents.
257
+ *
258
+ * Every log whose head names this working directory is followed: ownership is decided later, by the delivered text
259
+ * (TranscriptTurnLedger), so two muse instances in one directory cannot vouch for each other. The directory tree is
260
+ * re-listed at most every `listTtlMs`; between listings only the logs already known are read, and only when they grew.
261
+ * Logs present when the source is created are read from their end (existing history is not this launch's).
262
+ */
263
+ export class MuseSessionSource {
264
+ workingDirectory;
265
+ root;
266
+ listTtlMs;
267
+ now;
268
+ /**
269
+ * session.jsonl path → where to read next (cwd-matched logs only): a byte offset, or `{ anchor }` for a log that
270
+ * existed before this source (read from its last line boundary at `anchor` bytes, or at its size when first read
271
+ * if its size was not known then — never from its start).
272
+ */
273
+ files = new Map();
274
+ /** Every log present at the baseline, with its size then (null: it was there, its size could not be read). */
275
+ preexisting = new Map();
276
+ /** Whether the baseline listing saw the whole tree; if not, a log found later may be old and is read from its end. */
277
+ baselineComplete = false;
278
+ rejected = new Set();
279
+ sizes = new Map();
280
+ listedAt = Number.NEGATIVE_INFINITY;
281
+ baselined = false;
282
+ /** intent_id → its text and time, until it materializes (bounded). */
283
+ intents = new Map();
284
+ constructor(workingDirectory, root = join(homedir(), ".local", "share", "muse", "sessions"), listTtlMs = 30_000, now = () => performance.now()) {
285
+ this.workingDirectory = workingDirectory;
286
+ this.root = root;
287
+ this.listTtlMs = listTtlMs;
288
+ this.now = now;
289
+ }
290
+ reset() {
291
+ this.files.clear();
292
+ this.preexisting.clear();
293
+ this.rejected.clear();
294
+ this.sizes.clear();
295
+ this.intents.clear();
296
+ this.listedAt = Number.NEGATIVE_INFINITY;
297
+ this.baselined = false;
298
+ this.baselineComplete = false;
299
+ }
300
+ async initialize() { await this.list(); }
301
+ async list() {
302
+ this.listedAt = this.now();
303
+ const { logs, complete } = await listMuseSessionLogs(this.root);
304
+ if (!this.baselined) {
305
+ // The baseline is every log there now, whatever its head says: history is not this launch's, and a log whose
306
+ // cwd cannot be read yet is still history when it can.
307
+ for (const path of logs)
308
+ this.preexisting.set(path, await stat(path).then(st => st.size, () => null));
309
+ this.baselineComplete = complete;
310
+ this.baselined = true;
311
+ }
312
+ for (const path of logs) {
313
+ if (this.files.has(path) || this.rejected.has(path))
314
+ continue;
315
+ const head = readFileHeadSync(path, 65_536);
316
+ if (head === null)
317
+ continue;
318
+ const cwd = museSessionCwd(head);
319
+ if (cwd === null) {
320
+ // A log being started names its cwd within moments; one that still does not a minute on never will (the
321
+ // end-of-turn reminder observers write two such logs per turn) and is not read again.
322
+ const mtime = await stat(path).then(st => st.mtimeMs, () => null);
323
+ if (mtime !== null && Date.now() - mtime > 60_000)
324
+ this.rejected.add(path);
325
+ continue;
326
+ }
327
+ if (cwd !== this.workingDirectory) {
328
+ this.rejected.add(path);
329
+ continue;
330
+ }
331
+ // Read from the start only a log provably made after the baseline.
332
+ const fresh = this.baselineComplete && !this.preexisting.has(path);
333
+ this.files.set(path, fresh ? 0 : { anchor: this.preexisting.get(path) ?? null });
334
+ }
335
+ }
336
+ async poll() {
337
+ if (!this.baselined || this.now() - this.listedAt >= this.listTtlMs)
338
+ await this.list();
339
+ const turns = [];
340
+ for (const [path, next] of this.files) {
341
+ const size = await stat(path).then(st => st.size, () => null);
342
+ if (size === null || size === this.sizes.get(path))
343
+ continue;
344
+ let from;
345
+ if (typeof next === "number")
346
+ from = next;
347
+ else {
348
+ try {
349
+ from = await lastLineBoundary(path, next.anchor ?? size);
350
+ }
351
+ catch {
352
+ continue;
353
+ }
354
+ }
355
+ let read;
356
+ try {
357
+ read = await readNewLines(path, from);
358
+ }
359
+ catch {
360
+ continue;
361
+ }
362
+ this.files.set(path, read.newOffset);
363
+ this.sizes.set(path, size);
364
+ // One record at a time: a record of an unexpected shape is skipped, never the batch it came in.
365
+ for (const line of read.lines) {
366
+ try {
367
+ this.parse(line, turns);
368
+ }
369
+ catch { /* skipped */ }
370
+ }
371
+ }
372
+ const events = emptyEvents();
373
+ if (turns.length)
374
+ events.turns = turns;
213
375
  return events;
214
376
  }
377
+ parse(line, turns) {
378
+ let parsed;
379
+ try {
380
+ parsed = JSON.parse(line);
381
+ }
382
+ catch {
383
+ return;
384
+ }
385
+ const o = asRecord(parsed), p = asRecord(o?.payload);
386
+ if (!o || !p)
387
+ return;
388
+ const type = o.payload_type;
389
+ const at = typeof o.recorded_at === "number" && Number.isFinite(o.recorded_at) ? Math.floor(o.recorded_at / 1000) : NaN; // µs
390
+ if (type === "runtime.user_intent.accepted") {
391
+ if (p.surface !== "main" || typeof p.intent_id !== "string" || !Number.isFinite(at) || !Array.isArray(p.model_messages))
392
+ return;
393
+ const parts = [];
394
+ for (const message of p.model_messages) {
395
+ const content = asRecord(message)?.content;
396
+ if (!Array.isArray(content))
397
+ return;
398
+ for (const block of content) {
399
+ const b = asRecord(block);
400
+ if (b?.kind === "text" && typeof b.text === "string")
401
+ parts.push(b.text);
402
+ }
403
+ }
404
+ const text = parts.join("\n");
405
+ if (!text.trim())
406
+ return;
407
+ this.intents.set(p.intent_id, { text, at });
408
+ if (this.intents.size > 64)
409
+ this.intents.delete(this.intents.keys().next().value);
410
+ }
411
+ else if (type === "runtime.user_intent.materialized") {
412
+ const intent = typeof p.intent_id === "string" ? this.intents.get(p.intent_id) : undefined;
413
+ const runId = asRecord(p.outcome)?.run_id;
414
+ if (!intent || typeof runId !== "string")
415
+ return;
416
+ this.intents.delete(p.intent_id);
417
+ turns.push({ kind: "user", turnId: runId, text: intent.text, at: intent.at });
418
+ }
419
+ else if (type === "runtime.session" && p.kind === "run" && typeof p.run_id === "string") {
420
+ const event = asRecord(p.event);
421
+ if (event?.kind === "started")
422
+ turns.push({ kind: "start", turnId: p.run_id });
423
+ else if (event?.kind === "terminal" && "reason" in event) {
424
+ // Only what was recorded proves anything: an explicit null is a run that finished; a missing or unfamiliar
425
+ // reason ends nothing (the turn stays open, and the guard's running cap stands it down).
426
+ const reason = event.reason;
427
+ if (reason === null)
428
+ turns.push({ kind: "end", turnId: p.run_id, end: "complete" });
429
+ else if (typeof reason === "string")
430
+ turns.push({ kind: "end", turnId: p.run_id, end: reason.startsWith("cancelled") ? "aborted" : "error" });
431
+ }
432
+ }
433
+ }
434
+ }
435
+ function asRecord(value) {
436
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
437
+ }
438
+ /**
439
+ * `<root>/<YYYY>/<MM>/<DD>/<id>/session.jsonl` for every session directory. `complete` is false when a directory
440
+ * that exists could not be read: what it holds is unknown. A root that does not exist yet is complete (empty).
441
+ */
442
+ async function listMuseSessionLogs(root) {
443
+ let complete = true;
444
+ const children = async (dir, isRoot = false) => {
445
+ try {
446
+ return await readdir(dir);
447
+ }
448
+ catch (err) {
449
+ if (!(isRoot && err.code === "ENOENT"))
450
+ complete = false;
451
+ return [];
452
+ }
453
+ };
454
+ const logs = [];
455
+ for (const year of await children(root, true)) {
456
+ if (!/^\d{4}$/.test(year))
457
+ continue;
458
+ for (const month of await children(join(root, year))) {
459
+ for (const day of await children(join(root, year, month))) {
460
+ for (const session of await children(join(root, year, month, day)))
461
+ logs.push(join(root, year, month, day, session, "session.jsonl"));
462
+ }
463
+ }
464
+ }
465
+ return { logs, complete };
215
466
  }
216
467
  /* -------------------------------------------------------------------- kiro */
217
468
  /**
@@ -294,6 +545,18 @@ includeHistory = true) {
294
545
  * Kiro 2.19 moved primary conversations to conversations_v2 in data.sqlite3;
295
546
  * legacy releases use <uuid>.jsonl plus sibling <uuid>.json metadata.
296
547
  */
548
+ /** The Kiro legacy fallback's scan bounds (#1490): see KiroSessionSource.resolveActiveSession. */
549
+ export const KIRO_FULL_SCAN_MS = 30_000;
550
+ export const KIRO_QUIET_MS = 10 * 60_000;
551
+ export const KIRO_SCAN_CONCURRENCY = 16;
552
+ const KIRO_HOT_DIR_MS = 2_000;
553
+ /** Run `task(0..count-1)`, at most `limit` at a time. */
554
+ async function forEachLimited(count, limit, task) {
555
+ let next = 0;
556
+ const worker = async () => { while (next < count)
557
+ await task(next++); };
558
+ await Promise.all(Array.from({ length: Math.min(limit, count) }, worker));
559
+ }
297
560
  export class KiroSessionSource {
298
561
  workingDirectory;
299
562
  sessionsDir;
@@ -365,26 +628,59 @@ export class KiroSessionSource {
365
628
  * `null` = the file does not concern us (another cwd, or a subagent child).
366
629
  */
367
630
  metaCache = new Map();
368
- resolveActiveSession() {
369
- let entries;
631
+ /** The sessions directory as last listed: re-listed only when its mtime moves (a session added or removed). */
632
+ sessionsListing = null;
633
+ fullScanAt = Number.NEGATIVE_INFINITY;
634
+ /** Monotonic clock for the full-scan spacing; wall clock for comparing with file times. Test hooks. */
635
+ mono = () => performance.now();
636
+ wall = () => Date.now();
637
+ /**
638
+ * The newest session of this working directory, from its metadata (#1490). It used to run `readdirSync` plus a
639
+ * `statSync` of every metadata file on the event loop, on every 2 s poll while the store had no row for us: 16–18 ms
640
+ * per instance with 5,000 sessions. Now it is asynchronous and bounded:
641
+ * - the directory is listed again only when its mtime moved, or when it was read right after a change (file
642
+ * times are coarse: a session created in the same tick would share that mtime), or on a full scan;
643
+ * - a metadata file quiet for KIRO_QUIET_MS is not stat'ed again until the next full scan, so a poll costs one
644
+ * stat of the directory plus the recently active files; an old session resumed is seen within KIRO_FULL_SCAN_MS;
645
+ * - every full scan (each KIRO_FULL_SCAN_MS) re-stats everything, KIRO_SCAN_CONCURRENCY at a time.
646
+ */
647
+ async resolveActiveSession() {
648
+ const now = this.wall();
649
+ const full = this.mono() - this.fullScanAt >= KIRO_FULL_SCAN_MS;
650
+ let dirMtimeMs;
370
651
  try {
371
- entries = readdirSync(this.sessionsDir);
652
+ dirMtimeMs = (await stat(this.sessionsDir)).mtimeMs;
372
653
  }
373
654
  catch {
374
655
  return null;
375
656
  }
376
- let best = null;
377
- const seen = new Set();
378
- for (const e of entries) {
379
- if (!e.endsWith(".json") || e.endsWith(".jsonl"))
380
- continue;
381
- const metaPath = join(this.sessionsDir, e);
382
- seen.add(metaPath);
657
+ let listing = this.sessionsListing;
658
+ if (full || !listing || listing.mtimeMs !== dirMtimeMs || listing.hotWhenRead) {
659
+ let entries;
383
660
  try {
384
- const st = statSync(metaPath);
385
- let cached = this.metaCache.get(metaPath);
661
+ entries = await readdir(this.sessionsDir);
662
+ }
663
+ catch {
664
+ return null;
665
+ }
666
+ listing = { mtimeMs: dirMtimeMs, names: entries.filter(e => e.endsWith(".json") && !e.endsWith(".jsonl")).sort(), hotWhenRead: now - dirMtimeMs < KIRO_HOT_DIR_MS };
667
+ this.sessionsListing = listing;
668
+ }
669
+ if (full)
670
+ this.fullScanAt = this.mono();
671
+ const verdicts = new Array(listing.names.length).fill(null);
672
+ const names = listing.names;
673
+ await forEachLimited(names.length, KIRO_SCAN_CONCURRENCY, async (i) => {
674
+ const metaPath = join(this.sessionsDir, names[i]);
675
+ let cached = this.metaCache.get(metaPath);
676
+ if (!full && cached && now - cached.mtimeMs > KIRO_QUIET_MS) {
677
+ verdicts[i] = cached.meta;
678
+ return;
679
+ }
680
+ try {
681
+ const st = await stat(metaPath);
386
682
  if (!cached || cached.mtimeMs !== st.mtimeMs || cached.size !== st.size) {
387
- const meta = JSON.parse(readFileSync(metaPath, "utf-8"));
683
+ const meta = JSON.parse(await readFile(metaPath, "utf-8"));
388
684
  // Subagent sessions are children of a turn already being reported.
389
685
  const ours = meta.cwd === this.workingDirectory && meta.session_created_reason !== "subagent";
390
686
  cached = {
@@ -396,21 +692,31 @@ export class KiroSessionSource {
396
692
  };
397
693
  this.metaCache.set(metaPath, cached);
398
694
  }
399
- if (!cached.meta)
400
- continue;
401
- if (!best || cached.meta.updated > best.updated) {
402
- const jsonlPath = join(this.sessionsDir, e.replace(/\.json$/, ".jsonl"));
403
- best = { jsonlPath, updated: cached.meta.updated, createdAtMs: cached.meta.created };
404
- }
695
+ verdicts[i] = cached.meta;
405
696
  }
406
- catch { /* partially written metadata — next poll */ }
407
- }
697
+ catch { /* partially written metadata, or deleted meanwhile — next poll */ }
698
+ });
699
+ const seen = new Set(names.map(e => join(this.sessionsDir, e)));
408
700
  for (const known of this.metaCache.keys())
409
701
  if (!seen.has(known))
410
702
  this.metaCache.delete(known);
411
- return best && existsSync(best.jsonlPath)
412
- ? { jsonlPath: best.jsonlPath, createdAtMs: best.createdAtMs }
413
- : null;
703
+ let best = null;
704
+ names.forEach((e, i) => {
705
+ const meta = verdicts[i];
706
+ if (meta && (!best || meta.updated > best.updated)) {
707
+ best = { jsonlPath: join(this.sessionsDir, e.replace(/\.json$/, ".jsonl")), updated: meta.updated, createdAtMs: meta.created };
708
+ }
709
+ });
710
+ const chosen = best;
711
+ if (!chosen)
712
+ return null;
713
+ try {
714
+ await stat(chosen.jsonlPath);
715
+ }
716
+ catch {
717
+ return null;
718
+ }
719
+ return { jsonlPath: chosen.jsonlPath, createdAtMs: chosen.createdAtMs };
414
720
  }
415
721
  async poll() {
416
722
  // Current Kiro stores the primary session in SQLite; JSONL is now mostly
@@ -421,8 +727,8 @@ export class KiroSessionSource {
421
727
  return EMPTY;
422
728
  if (dbEvents !== null)
423
729
  return dbEvents;
424
- const active = this.resolveActiveSession();
425
- if (!active)
730
+ const active = await this.resolveActiveSession();
731
+ if (generation !== this.generation || !active)
426
732
  return EMPTY;
427
733
  if (active.jsonlPath !== this.currentFile) {
428
734
  if (active.createdAtMs >= this.createdAt) {
@@ -590,6 +896,7 @@ export function createTranscriptSource(backend, workingDirectory,
590
896
  storeHome) {
591
897
  switch (backend) {
592
898
  case "codex": return new CodexRolloutSource(workingDirectory);
899
+ case "muse": return new MuseSessionSource(workingDirectory);
593
900
  // undefined keeps each parameter's own default; only the store moves.
594
901
  case "kiro-cli": return new KiroSessionSource(workingDirectory, undefined, undefined, kiroStoreDbPath(storeHome));
595
902
  case "opencode": return new OpenCodeDbSource(workingDirectory);