@sapiom/harness 0.1.6 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/dist/cli/args.d.ts +16 -0
  3. package/dist/cli/args.d.ts.map +1 -0
  4. package/dist/cli/args.js +80 -0
  5. package/dist/cli/args.js.map +1 -0
  6. package/dist/cli/bin.js +12 -65
  7. package/dist/cli/bin.js.map +1 -1
  8. package/dist/cli/settings.d.ts.map +1 -1
  9. package/dist/cli/settings.js +33 -1
  10. package/dist/cli/settings.js.map +1 -1
  11. package/dist/core/adapters/claude-code.d.ts +60 -2
  12. package/dist/core/adapters/claude-code.d.ts.map +1 -1
  13. package/dist/core/adapters/claude-code.js +119 -32
  14. package/dist/core/adapters/claude-code.js.map +1 -1
  15. package/dist/core/adapters/codex.d.ts +21 -2
  16. package/dist/core/adapters/codex.d.ts.map +1 -1
  17. package/dist/core/adapters/codex.js +53 -2
  18. package/dist/core/adapters/codex.js.map +1 -1
  19. package/dist/core/adapters/detect.d.ts +9 -0
  20. package/dist/core/adapters/detect.d.ts.map +1 -1
  21. package/dist/core/adapters/detect.js +9 -0
  22. package/dist/core/adapters/detect.js.map +1 -1
  23. package/dist/core/canvas-body.d.ts +28 -28
  24. package/dist/core/canvas-body.d.ts.map +1 -1
  25. package/dist/core/canvas-body.js +117 -55
  26. package/dist/core/canvas-body.js.map +1 -1
  27. package/dist/core/canvas-derive.d.ts +43 -0
  28. package/dist/core/canvas-derive.d.ts.map +1 -0
  29. package/dist/core/canvas-derive.js +95 -0
  30. package/dist/core/canvas-derive.js.map +1 -0
  31. package/dist/core/canvas-enrichment.d.ts +52 -127
  32. package/dist/core/canvas-enrichment.d.ts.map +1 -1
  33. package/dist/core/canvas-enrichment.js +17 -178
  34. package/dist/core/canvas-enrichment.js.map +1 -1
  35. package/dist/core/canvas-graph.d.ts +40 -1
  36. package/dist/core/canvas-graph.d.ts.map +1 -1
  37. package/dist/core/canvas-graph.js +71 -9
  38. package/dist/core/canvas-graph.js.map +1 -1
  39. package/dist/core/canvas-interconnections.d.ts +26 -8
  40. package/dist/core/canvas-interconnections.d.ts.map +1 -1
  41. package/dist/core/canvas-interconnections.js +132 -24
  42. package/dist/core/canvas-interconnections.js.map +1 -1
  43. package/dist/core/canvas-manifest-check.d.ts.map +1 -1
  44. package/dist/core/canvas-manifest-check.js +15 -2
  45. package/dist/core/canvas-manifest-check.js.map +1 -1
  46. package/dist/core/canvas-render.d.ts +8 -15
  47. package/dist/core/canvas-render.d.ts.map +1 -1
  48. package/dist/core/canvas-render.js +29 -41
  49. package/dist/core/canvas-render.js.map +1 -1
  50. package/dist/core/canvas-run-state.d.ts +41 -0
  51. package/dist/core/canvas-run-state.d.ts.map +1 -1
  52. package/dist/core/canvas-run-state.js +136 -0
  53. package/dist/core/canvas-run-state.js.map +1 -1
  54. package/dist/core/canvas-svg.d.ts +9 -4
  55. package/dist/core/canvas-svg.d.ts.map +1 -1
  56. package/dist/core/canvas-svg.js +84 -13
  57. package/dist/core/canvas-svg.js.map +1 -1
  58. package/dist/core/canvas-template.d.ts +9 -10
  59. package/dist/core/canvas-template.d.ts.map +1 -1
  60. package/dist/core/canvas-template.js +57 -20
  61. package/dist/core/canvas-template.js.map +1 -1
  62. package/dist/core/canvas-watcher.d.ts +22 -1
  63. package/dist/core/canvas-watcher.d.ts.map +1 -1
  64. package/dist/core/canvas-watcher.js +111 -30
  65. package/dist/core/canvas-watcher.js.map +1 -1
  66. package/dist/core/collector/normalizer.d.ts +8 -0
  67. package/dist/core/collector/normalizer.d.ts.map +1 -1
  68. package/dist/core/collector/normalizer.js +8 -0
  69. package/dist/core/collector/normalizer.js.map +1 -1
  70. package/dist/core/collector/store.d.ts +75 -1
  71. package/dist/core/collector/store.d.ts.map +1 -1
  72. package/dist/core/collector/store.js +334 -2
  73. package/dist/core/collector/store.js.map +1 -1
  74. package/dist/core/errors.d.ts +10 -4
  75. package/dist/core/errors.d.ts.map +1 -1
  76. package/dist/core/errors.js +11 -5
  77. package/dist/core/errors.js.map +1 -1
  78. package/dist/core/example-seed.d.ts +8 -0
  79. package/dist/core/example-seed.d.ts.map +1 -1
  80. package/dist/core/example-seed.js +47 -10
  81. package/dist/core/example-seed.js.map +1 -1
  82. package/dist/core/inject/system-prompt.d.ts +8 -0
  83. package/dist/core/inject/system-prompt.d.ts.map +1 -1
  84. package/dist/core/inject/system-prompt.js +3 -1
  85. package/dist/core/inject/system-prompt.js.map +1 -1
  86. package/dist/core/macros.d.ts.map +1 -1
  87. package/dist/core/macros.js +5 -6
  88. package/dist/core/macros.js.map +1 -1
  89. package/dist/core/paths.d.ts +1 -0
  90. package/dist/core/paths.d.ts.map +1 -1
  91. package/dist/core/paths.js +1 -0
  92. package/dist/core/paths.js.map +1 -1
  93. package/dist/core/record-archive.d.ts +187 -0
  94. package/dist/core/record-archive.d.ts.map +1 -0
  95. package/dist/core/record-archive.js +465 -0
  96. package/dist/core/record-archive.js.map +1 -0
  97. package/dist/core/rehydration.d.ts +72 -0
  98. package/dist/core/rehydration.d.ts.map +1 -0
  99. package/dist/core/rehydration.js +72 -0
  100. package/dist/core/rehydration.js.map +1 -0
  101. package/dist/core/resume-brief.d.ts +130 -0
  102. package/dist/core/resume-brief.d.ts.map +1 -0
  103. package/dist/core/resume-brief.js +404 -0
  104. package/dist/core/resume-brief.js.map +1 -0
  105. package/dist/core/rolling-summary.d.ts +130 -0
  106. package/dist/core/rolling-summary.d.ts.map +1 -0
  107. package/dist/core/rolling-summary.js +227 -0
  108. package/dist/core/rolling-summary.js.map +1 -0
  109. package/dist/core/session-manager.d.ts +11 -3
  110. package/dist/core/session-manager.d.ts.map +1 -1
  111. package/dist/core/session-manager.js +56 -7
  112. package/dist/core/session-manager.js.map +1 -1
  113. package/dist/core/session-record.d.ts +165 -0
  114. package/dist/core/session-record.d.ts.map +1 -0
  115. package/dist/core/session-record.js +470 -0
  116. package/dist/core/session-record.js.map +1 -0
  117. package/dist/core/spawn-target.d.ts +12 -0
  118. package/dist/core/spawn-target.d.ts.map +1 -0
  119. package/dist/core/spawn-target.js +187 -0
  120. package/dist/core/spawn-target.js.map +1 -0
  121. package/dist/core/task-manager.d.ts.map +1 -1
  122. package/dist/core/task-manager.js +9 -1
  123. package/dist/core/task-manager.js.map +1 -1
  124. package/dist/core/template-catalog.d.ts +48 -0
  125. package/dist/core/template-catalog.d.ts.map +1 -0
  126. package/dist/core/template-catalog.js +338 -0
  127. package/dist/core/template-catalog.js.map +1 -0
  128. package/dist/core/workflow-registry.d.ts.map +1 -1
  129. package/dist/core/workflow-registry.js +2 -2
  130. package/dist/core/workflow-registry.js.map +1 -1
  131. package/dist/core/workspace-watcher.d.ts.map +1 -1
  132. package/dist/core/workspace-watcher.js +4 -3
  133. package/dist/core/workspace-watcher.js.map +1 -1
  134. package/dist/index.d.ts +12 -0
  135. package/dist/index.d.ts.map +1 -1
  136. package/dist/index.js +21 -0
  137. package/dist/index.js.map +1 -1
  138. package/dist/profiles/default.d.ts.map +1 -1
  139. package/dist/profiles/default.js +9 -14
  140. package/dist/profiles/default.js.map +1 -1
  141. package/dist/server/canvas.d.ts.map +1 -1
  142. package/dist/server/canvas.js +7 -7
  143. package/dist/server/canvas.js.map +1 -1
  144. package/dist/server/fs.d.ts.map +1 -1
  145. package/dist/server/fs.js +17 -1
  146. package/dist/server/fs.js.map +1 -1
  147. package/dist/server/index.d.ts +12 -3
  148. package/dist/server/index.d.ts.map +1 -1
  149. package/dist/server/index.js +321 -50
  150. package/dist/server/index.js.map +1 -1
  151. package/dist/server/ingest.d.ts +11 -0
  152. package/dist/server/ingest.d.ts.map +1 -1
  153. package/dist/server/ingest.js +1 -0
  154. package/dist/server/ingest.js.map +1 -1
  155. package/dist/server/macros.d.ts +4 -6
  156. package/dist/server/macros.d.ts.map +1 -1
  157. package/dist/server/macros.js.map +1 -1
  158. package/dist/server/rest.d.ts +18 -6
  159. package/dist/server/rest.d.ts.map +1 -1
  160. package/dist/server/rest.js +185 -48
  161. package/dist/server/rest.js.map +1 -1
  162. package/dist/server/templates.d.ts +24 -0
  163. package/dist/server/templates.d.ts.map +1 -0
  164. package/dist/server/templates.js +54 -0
  165. package/dist/server/templates.js.map +1 -0
  166. package/dist/shared/types.d.ts +477 -29
  167. package/dist/shared/types.d.ts.map +1 -1
  168. package/dist/shared/types.js +19 -11
  169. package/dist/shared/types.js.map +1 -1
  170. package/dist/web/assets/index-DC65cskq.js +446 -0
  171. package/dist/web/assets/index-xtD-3FFa.css +32 -0
  172. package/dist/web/index.html +2 -2
  173. package/package.json +5 -4
  174. package/dist/core/canvas-enrich.d.ts +0 -115
  175. package/dist/core/canvas-enrich.d.ts.map +0 -1
  176. package/dist/core/canvas-enrich.js +0 -186
  177. package/dist/core/canvas-enrich.js.map +0 -1
  178. package/dist/profiles/canvas-guidelines.d.ts +0 -20
  179. package/dist/profiles/canvas-guidelines.d.ts.map +0 -1
  180. package/dist/profiles/canvas-guidelines.js +0 -48
  181. package/dist/profiles/canvas-guidelines.js.map +0 -1
  182. package/dist/web/assets/index-DCtyI4VW.css +0 -32
  183. package/dist/web/assets/index-WSw5RvYR.js +0 -445
  184. package/dist/web/welcome-hero-dark.png +0 -0
  185. package/dist/web/welcome-hero-light.png +0 -0
@@ -15,7 +15,6 @@ import { WebSocketServer } from "ws";
15
15
  import open from "open";
16
16
  import { JSON_BODY_LIMIT_BYTES } from "../shared/types.js";
17
17
  import { resolveStatePaths } from "../core/paths.js";
18
- import { seedExampleProject } from "../core/example-seed.js";
19
18
  import { SessionManager, } from "../core/session-manager.js";
20
19
  import { TaskManager } from "../core/task-manager.js";
21
20
  import { createClaudeCodeAdapter } from "../core/adapters/claude-code.js";
@@ -23,6 +22,10 @@ import { createCodexAdapter } from "../core/adapters/codex.js";
23
22
  import { WorkflowRegistry, createWorkflowsRouter, } from "../core/workflow-registry.js";
24
23
  import { DEFAULT_MACROS } from "../core/macros.js";
25
24
  import { createEventStore } from "../core/collector/store.js";
25
+ import { createClaudeTranscriptEnricher, createSessionRecordReader, } from "../core/session-record.js";
26
+ import { buildRehydrationBrief, systemPromptDeliveryFor, } from "../core/rehydration.js";
27
+ import { createRollingSummarizer, readRollingSummary, } from "../core/rolling-summary.js";
28
+ import { backfillSessionRecords, createRecordArchive, } from "../core/record-archive.js";
26
29
  import { createHarnessEmitter } from "../core/collector/analytics-emitter.js";
27
30
  import { migrateHarnessIdentity } from "../core/collector/identity-migration.js";
28
31
  import { normalizeHookEvent } from "../core/collector/normalizer.js";
@@ -30,7 +33,7 @@ import { enrichTurnCompleted } from "../core/collector/transcript.js";
30
33
  import { createSeqCounter } from "../core/collector/seq.js";
31
34
  import { findRolloutFile, tailCodexRollout, } from "../core/collector/codex-tailer.js";
32
35
  import { getOrCreateMachineId } from "../cli/machine-id.js";
33
- import { pruneDeadRecentDirs } from "../cli/settings.js";
36
+ import { loadSettings, pruneDeadRecentDirs } from "../cli/settings.js";
34
37
  import { generateClaudeSettings } from "../core/inject/claude-settings.js";
35
38
  import { generateMcpConfig } from "../core/inject/mcp-config.js";
36
39
  import { generateSystemPromptFile } from "../core/inject/system-prompt.js";
@@ -43,8 +46,8 @@ import { PortDetector, portFromUrl } from "../core/port-detector.js";
43
46
  import { EventBus } from "../core/event-bus.js";
44
47
  import { writeHarnessContext, harnessContextFileExists, } from "../core/workspace-context.js";
45
48
  import { ensureCanvasTemplate } from "../core/canvas-template.js";
46
- import { renderCanvasForSession, renderWorkflowRenderFile, } from "../core/canvas-render.js";
47
- import { CanvasEnrichmentCoordinator } from "../core/canvas-enrich.js";
49
+ import { renderCanvasForSession } from "../core/canvas-render.js";
50
+ import { invalidateExtractionCache } from "../core/canvas-cache.js";
48
51
  import { sweepNdjson } from "../core/collector/store-retention.js";
49
52
  import { createDefinitionSlugResolver, resolveAgentsBaseUrl, } from "../core/definition-slug-resolver.js";
50
53
  import { createBootTokenMiddleware } from "./auth.js";
@@ -60,6 +63,7 @@ import { createCanvasRenderRouter } from "./canvas-render.js";
60
63
  import { createMacrosRouter } from "./macros.js";
61
64
  import { createFsRouter } from "./fs.js";
62
65
  import { createRunsRouter } from "./runs.js";
66
+ import { createTemplatesRouter } from "./templates.js";
63
67
  import { createActionsRouter } from "./actions.js";
64
68
  import { createAuthRouter, createMutableAuthState, } from "./auth-routes.js";
65
69
  // resolveAgentsBaseUrl is imported above from definition-slug-resolver.js
@@ -100,6 +104,9 @@ function packageRoot() {
100
104
  function workflowListsEqual(a, b) {
101
105
  if (a.length !== b.length)
102
106
  return false;
107
+ // NUL separates the fields because it can't occur in any of them. Keep it
108
+ // written as the escape `\u0000` — a literal NUL byte in the source makes
109
+ // grep and ripgrep classify this whole file as binary and silently skip it.
103
110
  const key = (w) => `${w.path}\u0000${w.name}\u0000${w.definitionId ?? ""}\u0000${w.source}`;
104
111
  const setA = new Set(a.map(key));
105
112
  return b.every((w) => setA.has(key(w)));
@@ -123,8 +130,29 @@ function readVersion() {
123
130
  * so the remote `sapiom` MCP is actually authenticated — a factory rather
124
131
  * than a plain function since it's per-server-instance state.
125
132
  */
126
- function createDefaultBuildLaunchOpts(apiKey, generatedRoot) {
127
- return async (harnessSessionId) => {
133
+ function createDefaultBuildLaunchOpts(apiKey, generatedRoot, rehydration) {
134
+ return async (harnessSessionId, req) => {
135
+ // Portable continue (SAP-2059). Resolved before the prompt file is
136
+ // written, because for a `launch-flag` harness the brief IS part of that
137
+ // file. Best-effort throughout: a brief that can't be assembled leaves
138
+ // `rehydratedFrom` unset and the session launches as an ordinary fresh
139
+ // one rather than failing — see CreateSessionRequest.rehydrateFrom.
140
+ //
141
+ // Only ever set on create(): resume() calls this builder with the persisted
142
+ // HarnessSession, which carries no `rehydrateFrom`, so a rehydrated session
143
+ // that is later resumed does not get the brief a second time (the agent's
144
+ // own conversation already holds it).
145
+ const rehydrateFrom = req.rehydrateFrom;
146
+ const brief = rehydrateFrom && rehydration
147
+ ? await rehydration.buildBrief(rehydrateFrom).catch((err) => {
148
+ console.error("[harness] rehydration brief failed:", err);
149
+ return null;
150
+ })
151
+ : null;
152
+ // The other channel (post-ready injection, for a harness with no prompt
153
+ // flag) is driven from the session status handler in startServer — the
154
+ // brief must not go into a file that adapter never reads.
155
+ const viaSystemPrompt = brief !== null && rehydration?.deliveryFor(req.harness) === "launch-flag";
128
156
  const [settings, mcpConfigFile, systemPromptFile, pluginDir] = await Promise.all([
129
157
  generateClaudeSettings({ harnessSessionId, generatedRoot }),
130
158
  generateMcpConfig(harnessSessionId, {
@@ -132,7 +160,10 @@ function createDefaultBuildLaunchOpts(apiKey, generatedRoot) {
132
160
  apiKey,
133
161
  generatedRoot,
134
162
  }),
135
- generateSystemPromptFile(harnessSessionId, { generatedRoot }),
163
+ generateSystemPromptFile(harnessSessionId, {
164
+ generatedRoot,
165
+ ...(viaSystemPrompt ? { appendix: brief } : {}),
166
+ }),
136
167
  generateSkillsPlugin(harnessSessionId, { generatedRoot }),
137
168
  ]);
138
169
  return {
@@ -140,6 +171,9 @@ function createDefaultBuildLaunchOpts(apiKey, generatedRoot) {
140
171
  mcpConfigFile,
141
172
  systemPromptFile,
142
173
  ...(pluginDir ? { pluginDir } : {}),
174
+ // Set on BOTH channels: the post-ready path hasn't delivered yet, but a
175
+ // brief exists and will, and this is the flag that tells it to.
176
+ ...(brief !== null && rehydrateFrom ? { rehydratedFrom: rehydrateFrom } : {}),
143
177
  };
144
178
  };
145
179
  }
@@ -202,6 +236,19 @@ export const startServer = async (options) => {
202
236
  const bus = new EventBus();
203
237
  const canvasWatcher = new CanvasWatcherManager({
204
238
  onChange: (harnessSessionId) => bus.publish({ type: "canvas.reload", harnessSessionId }),
239
+ // A workflow SOURCE edit auto re-renders the bound workflow — deterministic
240
+ // and free, so there's nothing to wait for and no button to press. Uses the
241
+ // preserve-on-failure path: while an agent is mid-edit the sources are
242
+ // transiently un-buildable, and flashing an extraction-error panel over a
243
+ // perfectly good diagram reads as broken. So keep the last good render until
244
+ // the edit builds cleanly, then swap it in (the write flows back through
245
+ // onChange above as the iframe reload, with its loading skeleton). Only a
246
+ // workflow that has never rendered shows the honest error.
247
+ onSourceChange: (harnessSessionId) => {
248
+ const session = sessionManager.get(harnessSessionId);
249
+ if (session)
250
+ void autoRenderCanvas(session).catch(() => { });
251
+ },
205
252
  });
206
253
  // The harness's own infrastructure shouldn't ever show up as a "discovered"
207
254
  // dev server in the Preview pane — a mention of our own listening port (in
@@ -293,6 +340,54 @@ export const startServer = async (options) => {
293
340
  : null;
294
341
  await writeHarnessContext(session, boundWorkflow, workflowsCache);
295
342
  };
343
+ // Declared before the launch-opts builder (rather than beside the ingest
344
+ // pipeline, where the rest of the event wiring lives) because portable
345
+ // continue resolves a rehydration brief from this reader at session-create
346
+ // time — see createDefaultBuildLaunchOpts below.
347
+ const eventStorePath = options.eventStorePath ?? statePaths.events;
348
+ const eventStore = createEventStore(eventStorePath);
349
+ // Archived session records — the copies that survive events.ndjson's 50 MB /
350
+ // 30-day retention (core/record-archive.ts). Declared here, above the session
351
+ // manager, because the "exited" handler archives through it.
352
+ const recordsRoot = options.recordsRoot ?? statePaths.records;
353
+ const recordArchive = createRecordArchive({ root: recordsRoot });
354
+ // Past-session transcripts, rebuilt from the events above rather than from
355
+ // any vendor's history file — the same code path for claude-code and codex.
356
+ // The claude enricher is a pure bonus on top: when that transcript happens
357
+ // to still exist, the final turn gains its model/usage; when it doesn't, the
358
+ // record renders unchanged.
359
+ const sessionRecordReader = createSessionRecordReader(eventStore, {
360
+ enrichFinalTurn: createClaudeTranscriptEnricher(),
361
+ archive: recordArchive,
362
+ });
363
+ /**
364
+ * Fold a session's record from the events and archive it — the write that
365
+ * makes its history outlive the log. Idempotent (it replaces any earlier
366
+ * archive of the same conversation), detached, and silent about nothing: a
367
+ * failure is logged and never propagated, because a session's exit must not
368
+ * fail over its bookkeeping.
369
+ *
370
+ * Folded via `readFromEvents`, deliberately not `read`: re-archiving whatever
371
+ * `read` returned would compact an already-compacted excerpt and re-stamp it
372
+ * with a fresh `archivedAt`, making a stale copy look current.
373
+ *
374
+ * The sweep runs right after a write because a write is the only thing that
375
+ * grows the store — enforcing the caps at the moment they can be exceeded
376
+ * beats waiting for the next boot.
377
+ */
378
+ const archiveSessionRecord = async (harnessSessionId) => {
379
+ const record = await sessionRecordReader.readFromEvents(harnessSessionId);
380
+ if (!record)
381
+ return;
382
+ if (!(await recordArchive.write(record)))
383
+ return;
384
+ await recordArchive.sweep();
385
+ };
386
+ const archiveSessionRecordDetached = (harnessSessionId) => {
387
+ void archiveSessionRecord(harnessSessionId).catch((err) => {
388
+ console.error("[harness] session record archive failed:", err);
389
+ });
390
+ };
296
391
  // Exit-time deletion of generated/<id> (see the onStatusChange handler
297
392
  // below) can race a fast resume(): resume regenerates the dir via
298
393
  // buildLaunchOpts, and the rm scheduled at the previous exit could still
@@ -300,8 +395,65 @@ export const startServer = async (options) => {
300
395
  // before (re)generating its files.
301
396
  const generatedRoot = options.generatedRoot ?? statePaths.generated;
302
397
  const pendingGeneratedRemovals = new Map();
398
+ /**
399
+ * The git branch the PRIOR session was last on, from whichever adapter
400
+ * recorded it — our own events never carry one. One adapter history scan per
401
+ * rehydrate, which is a user-initiated "continue this session" click rather
402
+ * than a hot path (the reason `GET /sessions/history` avoids per-row probes
403
+ * doesn't apply to a single deliberate action). Null for a harness whose
404
+ * transcript doesn't record a branch, and never throws.
405
+ */
406
+ const priorGitBranch = async (record) => {
407
+ if (!record.cwd || !record.agentSessionId)
408
+ return null;
409
+ const adapter = adapters[record.harness];
410
+ if (!adapter)
411
+ return null;
412
+ try {
413
+ const rows = await adapter.listPastSessions(record.cwd);
414
+ return rows.find((row) => row.agentSessionId === record.agentSessionId)?.gitBranch ?? null;
415
+ }
416
+ catch {
417
+ return null;
418
+ }
419
+ };
420
+ /**
421
+ * Portable continue: the brief text for a `rehydrateFrom` id, or null when
422
+ * our event log holds nothing for it.
423
+ *
424
+ * Closes over `sessionManager` (declared just below, since it consumes the
425
+ * builder this feeds) for the prior session's title and workflow binding —
426
+ * safe because nothing can create a session before the manager that creates
427
+ * them exists.
428
+ */
429
+ const resolveRehydrationBrief = (rehydrateFrom) => buildRehydrationBrief(rehydrateFrom, {
430
+ readRecord: (id) => sessionRecordReader.read(id),
431
+ readSummary: (harnessSessionId) => readRollingSummary(generatedRoot, harnessSessionId),
432
+ resolveContext: async (record) => {
433
+ // The earliest merged session the registry still knows — the record's
434
+ // own primary id first, so a conversation that spans a resume reports
435
+ // where it began rather than whichever segment happens to be indexed.
436
+ const prior = record.mergedSessionIds
437
+ .map((id) => sessionManager.get(id))
438
+ .find((session) => session !== undefined);
439
+ const workflowPath = prior?.boundWorkflowPath ?? null;
440
+ const workflow = workflowPath
441
+ ? (workflowsCache.find((w) => w.path === workflowPath) ?? null)
442
+ : null;
443
+ return {
444
+ title: prior?.title ?? null,
445
+ gitBranch: await priorGitBranch(record),
446
+ workflow: workflow
447
+ ? { name: workflow.name, path: workflow.path, definitionId: workflow.definitionId }
448
+ : null,
449
+ };
450
+ },
451
+ });
303
452
  const innerBuildLaunchOpts = options.buildLaunchOpts ??
304
- createDefaultBuildLaunchOpts(identity?.apiKey ?? null, generatedRoot);
453
+ createDefaultBuildLaunchOpts(identity?.apiKey ?? null, generatedRoot, {
454
+ buildBrief: resolveRehydrationBrief,
455
+ deliveryFor: (harness) => systemPromptDeliveryFor(adapters[harness]),
456
+ });
305
457
  const buildLaunchOpts = async (harnessSessionId, req) => {
306
458
  await pendingGeneratedRemovals.get(harnessSessionId);
307
459
  return innerBuildLaunchOpts(harnessSessionId, req);
@@ -322,6 +474,41 @@ export const startServer = async (options) => {
322
474
  await sessionManager.init();
323
475
  const sessionSweepTimer = setInterval(() => sessionManager.sweepDeadSessions(), SESSION_LIVENESS_SWEEP_MS);
324
476
  sessionSweepTimer.unref?.();
477
+ // Portable continue, second channel: for a harness whose adapter never puts
478
+ // `systemPromptFile` in front of the agent, the brief is injected once the
479
+ // session reports `ready` — not merely "running", which for a TUI sitting on
480
+ // a "trust this directory?" prompt would feed the brief to the prompt. No
481
+ // adapter shipped today needs this (both declare `launch-flag`), so it is
482
+ // dormant rather than dead: a new harness with no prompt flag gets working
483
+ // rehydration from one line in its adapter.
484
+ const briefsDelivered = new Set();
485
+ sessionManager.onStatusChange((session) => {
486
+ if (session.status === "exited") {
487
+ briefsDelivered.delete(session.id);
488
+ return;
489
+ }
490
+ const rehydratedFrom = session.rehydratedFrom;
491
+ if (!session.ready || !rehydratedFrom)
492
+ return;
493
+ if (systemPromptDeliveryFor(adapters[session.harness]) !== "post-ready-injection")
494
+ return;
495
+ if (briefsDelivered.has(session.id))
496
+ return;
497
+ // Claimed before the await so a burst of status frames can't double-inject.
498
+ briefsDelivered.add(session.id);
499
+ void (async () => {
500
+ const brief = await resolveRehydrationBrief(rehydratedFrom);
501
+ if (!brief)
502
+ return;
503
+ // submit:true — the brief has to enter the conversation to be context at
504
+ // all; left unsubmitted it would just sit in the composer as a wall of
505
+ // text the user has to deal with before they can type.
506
+ await sessionManager.submitInput(session.id, brief, true);
507
+ })().catch((err) => {
508
+ briefsDelivered.delete(session.id);
509
+ console.error("[harness] post-ready rehydration injection failed:", err);
510
+ });
511
+ });
325
512
  // Background tasks (canvas enrichment today): headless one-shot agent
326
513
  // runs that never touch a user's interactive session. They
327
514
  // reuse the exact same per-id config generation as sessions — a task's
@@ -343,6 +530,24 @@ export const startServer = async (options) => {
343
530
  taskManager.onStatusChange((task) => {
344
531
  bus.publish({ type: "task.status", task });
345
532
  });
533
+ // Rolling summary (opt-in, `HarnessSettings.rollingSummary`): folds a live
534
+ // session's record into a ≤500-word summary.md that a later portable
535
+ // continue reads. Everything about it is detached from the session's own
536
+ // path — `noteEvent` is synchronous and the fold is a separate headless
537
+ // process — so a turn is never slower or riskier for it being on. A codex
538
+ // session produces none (no `launchTask`); its briefs degrade to
539
+ // last-N-turns, which is also the default for everyone with the setting off.
540
+ const rollingSummarizer = createRollingSummarizer({
541
+ generatedRoot,
542
+ enabled: async () => (await loadSettings(statePaths.settings)).rollingSummary === true,
543
+ readRecord: (harnessSessionId) => sessionRecordReader.read(harnessSessionId),
544
+ getSession: (harnessSessionId) => {
545
+ const session = sessionManager.get(harnessSessionId);
546
+ return session ? { harness: session.harness, cwd: session.cwd } : undefined;
547
+ },
548
+ runTask: (req) => taskManager.run(req),
549
+ });
550
+ taskManager.onStatusChange((task) => rollingSummarizer.noteTaskStatus(task));
346
551
  // One boot-time sweep for generated dirs the exit-time cleanup below never
347
552
  // reached (crashes, force-kills, accumulation from before retention
348
553
  // existed). Age-gated (GENERATED_SWEEP_MAX_AGE_MS) and skips live
@@ -436,6 +641,15 @@ export const startServer = async (options) => {
436
641
  void rescanWorkspaceForSession(session.id).catch((err) => {
437
642
  console.error("[harness] initial workspace rescan failed:", err);
438
643
  });
644
+ // A resumed/already-bound session skips the rescan's auto-bind render
645
+ // (guarded by !boundWorkflowPath), so render it here — a reopened
646
+ // workflow shows its diagram on start without any manual trigger, now
647
+ // that the empty-state render button is gone.
648
+ if (session.boundWorkflowPath) {
649
+ void autoRenderCanvas(session).catch((err) => {
650
+ console.error("[harness] on-start canvas render failed:", err);
651
+ });
652
+ }
439
653
  }
440
654
  }
441
655
  else if (session.status === "exited") {
@@ -445,6 +659,13 @@ export const startServer = async (options) => {
445
659
  executionDetector.reset(session.id);
446
660
  // Let a resumed session get a fresh on-start rescan.
447
661
  rescannedOnStart.delete(session.id);
662
+ // Archive the record now the session is over, so its history survives
663
+ // events.ndjson's retention. This path is what covers a session that
664
+ // exited WITHOUT a SessionEnd hook (killed pty, crashed agent) and so
665
+ // never produced the `session.end` event the ingest path archives on;
666
+ // when both fire, the second write simply replaces the first with the
667
+ // fuller record.
668
+ archiveSessionRecordDetached(session.id);
448
669
  // The generated config dir is dead once the pty is: every file in it
449
670
  // is regenerated by buildLaunchOpts on resume, and the agent's last
450
671
  // emit.cjs execution (SessionEnd) happens before its process exits.
@@ -495,43 +716,26 @@ export const startServer = async (options) => {
495
716
  bus.publish({ type: "workflows.changed" });
496
717
  return found;
497
718
  };
498
- // The AI enrichment layer over the deterministic renders: one bounded
499
- // headless task per workflow (spawned on bind when no fresh cache exists,
500
- // or by the visualize macro's force refresh), whose validated JSON output
501
- // is persisted per-workflow and merged into the render on completion
502
- // the render write alone hot-reloads any open pane via the canvas watcher.
503
- const enrichment = new CanvasEnrichmentCoordinator({
504
- tasks: taskManager,
505
- rerender: async (cwd, workflow) => {
506
- await renderWorkflowRenderFile(cwd, workflow);
507
- },
508
- });
509
- // Renders a session's bound workflow via the deterministic pipeline —
510
- // always against the live workflowsCache, never an LLM in the render
511
- // itself; a cheap no-op for an unbound session (the canvas router serves
512
- // the empty state on its own). Never throws (see core/canvas-render.ts);
513
- // best-effort, like every other canvas write here. The bind path
514
- // (PATCH /sessions/:id/workflow → rest.ts) and the UNPROMPTED call sites
515
- // (session-create/boot auto-render, via autoRenderCanvas — which won't
516
- // replace a workflow's existing render with an error panel when its
517
- // extraction fails) also kick the enrichment freshness check afterwards:
518
- // instant base diagram now, annotations merged in when the task lands.
719
+ // Renders a session's bound workflow via the fully deterministic pipeline
720
+ // always against the live workflowsCache; structure + derived annotations,
721
+ // no LLM, no user token. A cheap no-op for an unbound session (the canvas
722
+ // router serves the empty state on its own). Never throws (see
723
+ // core/canvas-render.ts); best-effort, like every other canvas write here.
724
+ // autoRenderCanvas is the UNPROMPTED variant (session-create/boot) that
725
+ // won't replace a workflow's existing render with an error panel when its
726
+ // extraction fails.
519
727
  const renderCanvas = async (session) => {
520
728
  await renderCanvasForSession(session, workflowsCache);
521
- await enrichment.ensureFresh(session, workflowsCache);
522
729
  };
523
730
  const autoRenderCanvas = async (session) => {
524
731
  await renderCanvasForSession(session, workflowsCache, {
525
732
  preserveExistingOnFailure: true,
526
733
  });
527
- await enrichment.ensureFresh(session, workflowsCache);
528
734
  };
529
735
  const initialWorkflowScan = scanWorkflowsAndBroadcast(launchDir).catch((err) => {
530
736
  console.error("[harness] initial workflow scan failed:", err);
531
737
  return [];
532
738
  });
533
- const eventStorePath = options.eventStorePath ?? statePaths.events;
534
- const eventStore = createEventStore(eventStorePath);
535
739
  // Boot-time retention sweep: keeps events.ndjson within the 50 MB / 30-day
536
740
  // caps even on long-lived installs. Runs through the store's exclusive queue
537
741
  // so the sweep's read→filter→rename window never races a concurrent append.
@@ -546,6 +750,37 @@ export const startServer = async (options) => {
546
750
  runNdjsonSweep();
547
751
  const ndjsonRetentionTimer = setInterval(runNdjsonSweep, NDJSON_RETENTION_SWEEP_MS);
548
752
  ndjsonRetentionTimer.unref?.();
753
+ // One boot-time pass that archives conversations the log still holds but the
754
+ // archive doesn't, then sweeps the archive's own caps. This is what covers the
755
+ // two cases archiving-at-exit can't: a harness that was force-killed (no exit
756
+ // transition, no session.end), and every session that ended before this
757
+ // existed — whose history would otherwise vanish at its 30-day mark.
758
+ //
759
+ // It races the ndjson sweep queued above, and deliberately doesn't wait for
760
+ // it: reads run outside the store's exclusive queue by design (see store.ts),
761
+ // and either order is correct here — win the race and the record is archived
762
+ // from bytes retention was about to delete, lose it and the record is archived
763
+ // from what survived. Both beat not archiving it.
764
+ //
765
+ // Fire-and-forget: boot must not wait on it. The cost is one full index build
766
+ // (~130 ms against a 50 MB log), which the first history open would have paid
767
+ // anyway.
768
+ void backfillSessionRecords({
769
+ conversationIds: () => sessionRecordReader.conversationIds(),
770
+ readFromEvents: (id) => sessionRecordReader.readFromEvents(id),
771
+ archive: recordArchive,
772
+ isLiveSession: (harnessSessionId) => {
773
+ const session = sessionManager.get(harnessSessionId);
774
+ return session !== undefined && session.status !== "exited";
775
+ },
776
+ onCapped: (remaining) => {
777
+ console.error(`[harness] session record backfill hit its per-boot cap; ${remaining} conversation(s) left for the next boot`);
778
+ },
779
+ })
780
+ .then(() => recordArchive.sweep())
781
+ .catch((err) => {
782
+ console.error("[harness] session record backfill failed:", err);
783
+ });
549
784
  const harnessVersion = readVersion();
550
785
  const batcher = createHarnessEmitter({
551
786
  telemetryOptIn: options.telemetryOptIn,
@@ -622,12 +857,14 @@ export const startServer = async (options) => {
622
857
  });
623
858
  },
624
859
  launchDir,
860
+ defaultProjectRoot: options.projectRoot ?? launchDir,
625
861
  agentsBaseUrl: resolveAgentsBaseUrl(),
626
862
  availableHarnesses: options.availableHarnesses,
627
863
  listTasks: () => taskManager.list(),
628
864
  firstRun: options.firstRun,
629
865
  consentSource: options.consentSource,
630
866
  consentEnvReason: options.consentEnvReason,
867
+ sessionRecords: sessionRecordReader,
631
868
  uiTrack: {
632
869
  store: eventStore,
633
870
  batcher,
@@ -636,12 +873,6 @@ export const startServer = async (options) => {
636
873
  userId: identity?.userId ?? null,
637
874
  tenantId: identity?.tenantId ?? null,
638
875
  },
639
- seedSampleProject: async () => {
640
- const { root, projectDir, created } = await seedExampleProject({
641
- targetRoot: options.sampleProjectRoot ?? statePaths.sampleProject,
642
- });
643
- return { root, projectDir, created };
644
- },
645
876
  settingsPath: statePaths.settings,
646
877
  }));
647
878
  app.use("/api", createCanvasRenderRouter({
@@ -663,6 +894,12 @@ export const startServer = async (options) => {
663
894
  apiKey: apiKeyProvider,
664
895
  baseUrl: resolveAgentsBaseUrl(),
665
896
  }));
897
+ // The template gallery, relayed from CORE (not the agents surface) so the
898
+ // Studio's picker and the dashboard's Template library read one catalog.
899
+ // baseUrl omitted: the router self-defaults via resolveCoreBaseUrl().
900
+ app.use(createTemplatesRouter({
901
+ apiKey: apiKeyProvider,
902
+ }));
666
903
  // Direct action macros (Deploy / Prod-run) — server-side, key never reaches
667
904
  // the browser, no Claude Code. Resolves a workflow :id (its path) against the
668
905
  // same live cache the rest router's findWorkflow uses.
@@ -680,15 +917,17 @@ export const startServer = async (options) => {
680
917
  findWorkflow: (workflowPath) => workflowsCache.find((w) => w.path === workflowPath) ?? null,
681
918
  getSessionCwd: (harnessSessionId) => sessionManager.get(harnessSessionId)?.cwd ?? null,
682
919
  getBoundWorkflowPath: (harnessSessionId) => sessionManager.get(harnessSessionId)?.boundWorkflowPath ?? null,
683
- // The visualize macro is a FORCE refresh, not a plain re-render:
684
- // invalidate the extraction + enrichment caches, re-render the base
685
- // instantly, re-spawn the enrichment task. Its refusals map to the
686
- // router's existing error handling (already running → 409, codex
687
- // session → 400).
920
+ // The visualize/refresh macro forces a fresh deterministic re-render:
921
+ // drop the bound workflow's extraction cache so a source change (new or
922
+ // removed steps) is picked up, then re-render. Fully deterministic and
923
+ // instant no task, no user token, nothing to already-be-running.
688
924
  renderCanvas: async (harnessSessionId) => {
689
925
  const session = sessionManager.get(harnessSessionId);
690
- if (session)
691
- await enrichment.forceRefresh(session, workflowsCache);
926
+ if (!session)
927
+ return;
928
+ if (session.boundWorkflowPath)
929
+ invalidateExtractionCache(session.boundWorkflowPath);
930
+ await renderCanvas(session);
692
931
  },
693
932
  injectInput: async (harnessSessionId, text, submit) => {
694
933
  // Two-phase write: a combined text+\r lands in Claude Code as a
@@ -745,6 +984,9 @@ export const startServer = async (options) => {
745
984
  batcher,
746
985
  enrichFromTranscript: enrichTurnCompleted,
747
986
  onNormalizedEvent: (event) => {
987
+ // Synchronous and total — it counts turns and detaches any fold it
988
+ // decides to start, so the ingest path never waits on a summary.
989
+ rollingSummarizer.noteEvent(event);
748
990
  if (event.type !== "tool.call")
749
991
  return;
750
992
  const { toolInput, toolResponseSummary } = event.payload;
@@ -765,6 +1007,14 @@ export const startServer = async (options) => {
765
1007
  executionDetector.flush(event.harnessSessionId);
766
1008
  }
767
1009
  },
1010
+ onEventPersisted: (event) => {
1011
+ // The normal end of a session: the SessionEnd hook's event is in the
1012
+ // store, so the archived record carries the whole conversation including
1013
+ // its `endedAt`. (The "exited" status handler archives too, for sessions
1014
+ // that never get here.)
1015
+ if (event.type === "session.end")
1016
+ archiveSessionRecordDetached(event.harnessSessionId);
1017
+ },
768
1018
  onError: (err) => console.error("[harness] ingest processing error:", err),
769
1019
  seqCounter,
770
1020
  };
@@ -969,14 +1219,35 @@ export const startServer = async (options) => {
969
1219
  if (shutdownTimerHandle !== undefined)
970
1220
  clearTimeout(shutdownTimerHandle);
971
1221
  void batcher.close();
1222
+ // wss.close() stops NEW upgrades but never terminates the connections
1223
+ // already open — and httpServer.close() then waits indefinitely for those
1224
+ // sockets to drain. The main window's live /ws/events and /ws/terminal
1225
+ // connections (plus any keep-alive HTTP socket) would therefore hang
1226
+ // close() forever, so Electron's before-quit never reaches app.quit() and
1227
+ // the process lingers as a zombie still holding the single-instance lock —
1228
+ // which blocks the next launch (it hangs on "Starting Sapiom…"). Force
1229
+ // each client shut, drop keep-alive HTTP conns, and bound the final wait
1230
+ // so shutdown always completes.
1231
+ for (const client of terminalWss.clients)
1232
+ client.terminate();
1233
+ for (const client of eventsWss.clients)
1234
+ client.terminate();
972
1235
  terminalWss.close();
973
1236
  eventsWss.close();
974
- await new Promise((resolve, reject) => {
1237
+ httpServer.closeAllConnections?.();
1238
+ const HTTP_CLOSE_TIMEOUT_MS = 3000;
1239
+ await new Promise((resolve) => {
1240
+ let httpCloseTimer = setTimeout(() => {
1241
+ httpCloseTimer = undefined;
1242
+ resolve();
1243
+ }, HTTP_CLOSE_TIMEOUT_MS);
1244
+ httpCloseTimer.unref?.();
975
1245
  httpServer.close((err) => {
976
1246
  if (err)
977
- reject(err);
978
- else
979
- resolve();
1247
+ console.error("[harness] httpServer.close error:", err);
1248
+ if (httpCloseTimer !== undefined)
1249
+ clearTimeout(httpCloseTimer);
1250
+ resolve();
980
1251
  });
981
1252
  });
982
1253
  },