@openshain/mcp 0.4.0 → 0.5.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.
package/dist/server.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { relative } from "node:path";
2
2
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
3
  import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
4
- import { ASK_USER, compileInputValidator, countToolCalls, createToolCaller, createToolRegistry, DecisionFileSchema, isKnownEventType, isOpenshainError, isTerminal, loadAuthority, loadConfig, parsePayloadFile, parseWorkId, pendingApprovals, pendingQuestions, RUNTIME_PROVIDER_ID, resolveWorkspacePath, SESSION_WORK_TYPE, uuidv7, verifyArtifact, WorkStore, workHistory, writeDecision, } from "@openshain/core";
4
+ import { ASK_USER, businessDate, companyTime, compileInputValidator, countToolCalls, createToolCaller, createToolRegistry, DecisionFileSchema, isKnownEventType, isOpenshainError, isTerminal, loadAuthority, loadConfig, parsePayloadFile, parseWorkId, pendingApprovals, pendingQuestions, RUNTIME_PROVIDER_ID, resolveWorkspacePath, SESSION_WORK_TYPE, uuidv7, verifyArtifact, WorkStore, workHistory, writeDecision, } from "@openshain/core";
5
5
  import pkg from "../package.json" with { type: "json" };
6
6
  import { Session } from "./session.js";
7
7
  /** The tools every session has, before the workspace's own. Their names are reserved in the runtime. */
@@ -194,6 +194,7 @@ const WORK_TOOLS = [
194
194
  "model.completed",
195
195
  "model.failed",
196
196
  "usage.recorded",
197
+ "conversation.compacted",
197
198
  ],
198
199
  },
199
200
  payload: { type: "object" },
@@ -213,6 +214,8 @@ const RECORDABLE_TYPES = new Set([
213
214
  "model.completed",
214
215
  "model.failed",
215
216
  "usage.recorded",
217
+ // The conversation is the client's to shorten: the runtime holds the events either way.
218
+ "conversation.compacted",
216
219
  ]);
217
220
  const SESSION_HAS_NO_TOOLS = "a session records the conversation and runs no tools: call work_create with parent set to the session's id, then call the tool inside that work";
218
221
  const NO_WORK = "no current work: call work_create to start one for the person's request, or work_select to pick an existing one";
@@ -235,6 +238,20 @@ export async function createMcpServer(options) {
235
238
  authority: () => authority,
236
239
  });
237
240
  const works = new WorkStore(workspaceRoot);
241
+ /**
242
+ * Opens a work for one piece of work on it, and closes it however that ends. The lock a work
243
+ * holds is the single-writer rule, so it must not outlive the call that took it; releasing it
244
+ * here means no caller can forget to.
245
+ */
246
+ async function withWork(id, use) {
247
+ const opened = await works.open(id);
248
+ try {
249
+ return await use(opened);
250
+ }
251
+ finally {
252
+ await opened.close();
253
+ }
254
+ }
238
255
  const session = new Session();
239
256
  const server = new Server({ name: "openshain", version: pkg.version }, { capabilities: { tools: {} } });
240
257
  /** The current work when it can still take events; otherwise the reason it cannot. */
@@ -290,6 +307,11 @@ export async function createMcpServer(options) {
290
307
  const work = await works.get(id);
291
308
  if (isTerminal(work.status))
292
309
  return failure(`work ${id} is already ${work.status}`);
310
+ // Selecting is what lets a client record into a work. Another person's conversation is
311
+ // theirs: a client that could select it could write what they never said.
312
+ if (work.principal !== config.principal.id) {
313
+ return failure(`work ${id} belongs to ${work.principal}; a client selects only the works of the person it acts for`);
314
+ }
293
315
  session.select(id);
294
316
  return json({ ...work, history: workHistory(await works.events(id)) });
295
317
  }
@@ -315,18 +337,14 @@ export async function createMcpServer(options) {
315
337
  }
316
338
  const { question } = input;
317
339
  const callId = newCallId();
318
- const opened = await works.open(gate.id);
319
- try {
340
+ await withWork(gate.id, async (opened) => {
320
341
  await opened.append({
321
342
  type: "tool.called",
322
343
  payload: { callId, provider: RUNTIME_PROVIDER_ID, name: ASK_USER.name, input },
323
344
  });
324
345
  await opened.append({ type: "human.input_requested", payload: { callId, question } });
325
346
  await opened.transition("waiting_input", "the agent asked the person a question");
326
- }
327
- finally {
328
- await opened.close();
329
- }
347
+ });
330
348
  return json({ pending: true, call_id: callId, question });
331
349
  }
332
350
  case "work_answer": {
@@ -342,8 +360,7 @@ export async function createMcpServer(options) {
342
360
  if (!pending.some((q) => q.callId === callId)) {
343
361
  return failure(`no unanswered question with call_id ${callId}; pending: ${pending.map((q) => q.callId).join(", ") || "none"}`);
344
362
  }
345
- const opened = await works.open(gate.id);
346
- try {
363
+ return await withWork(gate.id, async (opened) => {
347
364
  await opened.append({ type: "human.input_provided", payload: { callId, answer } });
348
365
  await opened.append({
349
366
  type: "tool.completed",
@@ -351,10 +368,7 @@ export async function createMcpServer(options) {
351
368
  });
352
369
  await opened.transition("in_progress", "the person answered");
353
370
  return json(await opened.current());
354
- }
355
- finally {
356
- await opened.close();
357
- }
371
+ });
358
372
  }
359
373
  case "work_record": {
360
374
  const { work_id, type, payload } = input;
@@ -372,25 +386,21 @@ export async function createMcpServer(options) {
372
386
  if (type === "usage.recorded" && parsed.kind !== "model_inference") {
373
387
  return failure("usage.recorded from a client must have kind model_inference");
374
388
  }
375
- const opened = await works.open(id);
376
- try {
389
+ return await withWork(id, async (opened) => {
377
390
  const status = (await opened.current()).status;
378
391
  if (isTerminal(status))
379
392
  return failure(`work ${id} is already ${status}`);
380
393
  const event = await opened.append({ type, payload: parsed });
381
394
  return json({ id: event.id, seq: event.seq });
382
- }
383
- finally {
384
- await opened.close();
385
- }
395
+ });
386
396
  }
387
397
  case "context": {
388
398
  const now = new Date();
389
- const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
399
+ const timezone = config.company.timezone;
390
400
  const info = {
391
- now: localIso(now),
401
+ now: companyTime(timezone, now),
392
402
  timezone,
393
- business_date: localIso(now).slice(0, 10),
403
+ business_date: businessDate(timezone, now),
394
404
  workspace: workspaceRoot,
395
405
  company: config.company.name,
396
406
  principal: { id: config.principal.id, name: config.principal.name },
@@ -402,8 +412,7 @@ export async function createMcpServer(options) {
402
412
  const current = session.current;
403
413
  if (current && !isTerminal((await works.get(current)).status)) {
404
414
  const callId = newCallId();
405
- const opened = await works.open(current);
406
- try {
415
+ await withWork(current, async (opened) => {
407
416
  await opened.append({
408
417
  type: "tool.called",
409
418
  payload: { callId, provider: RUNTIME_PROVIDER_ID, name: "context", input: {} },
@@ -412,10 +421,7 @@ export async function createMcpServer(options) {
412
421
  type: "tool.completed",
413
422
  payload: { callId, content: [{ type: "json", value: info }], isError: false },
414
423
  });
415
- }
416
- finally {
417
- await opened.close();
418
- }
424
+ });
419
425
  }
420
426
  return result;
421
427
  }
@@ -444,8 +450,7 @@ export async function createMcpServer(options) {
444
450
  if (approval.approvers && !approval.approvers.includes(by)) {
445
451
  return failure(`${by} may not decide ${approvalId}; approvers: ${approval.approvers.join(", ")}`);
446
452
  }
447
- const opened = await works.open(workId);
448
- try {
453
+ return await withWork(workId, async (opened) => {
449
454
  // Under the lock: another connection may have decided this approval in between.
450
455
  if (!pendingApprovals(await opened.events()).some((a) => a.approvalId === approvalId)) {
451
456
  return failure(`approval ${approvalId} was already decided`);
@@ -475,10 +480,7 @@ export async function createMcpServer(options) {
475
480
  work_id: workId,
476
481
  result: { content: result.content, isError: result.isError ?? false },
477
482
  });
478
- }
479
- finally {
480
- await opened.close();
481
- }
483
+ });
482
484
  }
483
485
  case "review_decide": {
484
486
  const { approval_id: approvalId, decision, reviewer, interpretation, modified_input: modifiedInput, effective_from: effectiveFrom, effective_until: effectiveUntil, applies_to: appliesTo, } = input;
@@ -523,8 +525,7 @@ export async function createMcpServer(options) {
523
525
  }
524
526
  record = parsed.data;
525
527
  }
526
- const opened = await works.open(workId);
527
- try {
528
+ return await withWork(workId, async (opened) => {
528
529
  // Under the lock: another connection may have decided this approval in between.
529
530
  if (!pendingApprovals(await opened.events()).some((a) => a.approvalId === approvalId)) {
530
531
  return failure(`approval ${approvalId} was already decided`);
@@ -573,10 +574,7 @@ export async function createMcpServer(options) {
573
574
  decision_file: relative(workspaceRoot, file),
574
575
  result: { content: ran.content, isError: ran.isError ?? false },
575
576
  });
576
- }
577
- finally {
578
- await opened.close();
579
- }
577
+ });
580
578
  }
581
579
  case "work_list": {
582
580
  const { works: all, problems } = await works.list();
@@ -602,7 +600,7 @@ export async function createMcpServer(options) {
602
600
  if ("refused" in gate)
603
601
  return gate.refused;
604
602
  const { summary, artifacts } = input;
605
- const work = await complete(works, workspaceRoot, gate.id, summary, artifacts ?? []);
603
+ const work = await withWork(gate.id, (opened) => complete(workspaceRoot, opened, summary, artifacts ?? []));
606
604
  session.clear();
607
605
  return json(work);
608
606
  }
@@ -611,13 +609,9 @@ export async function createMcpServer(options) {
611
609
  if ("refused" in gate)
612
610
  return gate.refused;
613
611
  const { reason, detail } = input;
614
- const opened = await works.open(gate.id);
615
- try {
612
+ await withWork(gate.id, async (opened) => {
616
613
  await opened.append({ type: "work.failed", payload: { reason, detail: detail ?? "" } });
617
- }
618
- finally {
619
- await opened.close();
620
- }
614
+ });
621
615
  session.clear();
622
616
  return json(await works.get(gate.id));
623
617
  }
@@ -634,8 +628,7 @@ export async function createMcpServer(options) {
634
628
  if (work.status === "waiting_approval") {
635
629
  return failure(`work ${gate.id} is waiting for an approval; decide it with approval_decide (see approval_list) before calling tools`);
636
630
  }
637
- const opened = await works.open(gate.id);
638
- try {
631
+ return await withWork(gate.id, async (opened) => {
639
632
  const limit = config.limits.maxToolCalls;
640
633
  if (countToolCalls(await opened.events()) >= limit) {
641
634
  const reason = `this work has reached its limit of ${limit} tool calls; finish it with work_complete or work_fail`;
@@ -647,10 +640,7 @@ export async function createMcpServer(options) {
647
640
  }
648
641
  const result = await callTool(opened, { id: newCallId(), name, input });
649
642
  return toMcpResult(result);
650
- }
651
- finally {
652
- await opened.close();
653
- }
643
+ });
654
644
  }
655
645
  }
656
646
  }
@@ -674,38 +664,32 @@ export async function createMcpServer(options) {
674
664
  * wrote; every path must be inside the workspace, and the runtime hashes them all. A path no tool
675
665
  * of this work wrote is marked claimed, so a reader can tell the agent's word from the record.
676
666
  */
677
- async function complete(works, workspaceRoot, id, summary, claimed) {
667
+ async function complete(workspaceRoot, opened, summary, claimed) {
678
668
  for (const { path } of claimed)
679
669
  await resolveWorkspacePath(workspaceRoot, path);
680
- const opened = await works.open(id);
681
- try {
682
- const events = await opened.events();
683
- const refs = [];
684
- const byPath = new Map();
685
- for (const event of writesWithAfter(events)) {
686
- refs.push(event.id);
687
- for (const { path, sha256 } of event.payload.after ?? [])
688
- byPath.set(path, sha256);
689
- }
690
- const written = new Set(byPath.keys());
691
- for (const { path, sha256 } of claimed)
692
- if (!byPath.has(path))
693
- byPath.set(path, sha256 ?? "");
694
- const artifacts = [];
695
- for (const [path, reported] of byPath) {
696
- const artifact = await verifyArtifact(workspaceRoot, path, reported);
697
- artifacts.push(written.has(path) ? artifact : { ...artifact, claimed: true });
698
- }
699
- await opened.append({
700
- type: "evidence.recorded",
701
- payload: { claim: summary, refs, artifacts },
702
- });
703
- await opened.append({ type: "work.completed", payload: { summary } });
704
- return opened.current();
670
+ const events = await opened.events();
671
+ const refs = [];
672
+ const byPath = new Map();
673
+ for (const event of writesWithAfter(events)) {
674
+ refs.push(event.id);
675
+ for (const { path, sha256 } of event.payload.after ?? [])
676
+ byPath.set(path, sha256);
705
677
  }
706
- finally {
707
- await opened.close();
678
+ const written = new Set(byPath.keys());
679
+ for (const { path, sha256 } of claimed)
680
+ if (!byPath.has(path))
681
+ byPath.set(path, sha256 ?? "");
682
+ const artifacts = [];
683
+ for (const [path, reported] of byPath) {
684
+ const artifact = await verifyArtifact(workspaceRoot, path, reported);
685
+ artifacts.push(written.has(path) ? artifact : { ...artifact, claimed: true });
708
686
  }
687
+ await opened.append({
688
+ type: "evidence.recorded",
689
+ payload: { claim: summary, refs, artifacts },
690
+ });
691
+ await opened.append({ type: "work.completed", payload: { summary } });
692
+ return opened.current();
709
693
  }
710
694
  function writesWithAfter(events) {
711
695
  return events.filter((e) => e.type === "tool.completed" &&
@@ -749,13 +733,3 @@ async function findApproval(works, approvalId) {
749
733
  }
750
734
  return undefined;
751
735
  }
752
- /** ISO 8601 with the local offset instead of Z, so the time reads as the person's clock. */
753
- function localIso(date) {
754
- const pad = (n) => String(n).padStart(2, "0");
755
- const offset = -date.getTimezoneOffset();
756
- const sign = offset >= 0 ? "+" : "-";
757
- const abs = Math.abs(offset);
758
- return (`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
759
- `T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}` +
760
- `${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`);
761
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openshain/mcp",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "MCP server that exposes an openshain workspace to any agent",
5
5
  "keywords": [
6
6
  "openshain",
@@ -46,10 +46,10 @@
46
46
  },
47
47
  "dependencies": {
48
48
  "@modelcontextprotocol/sdk": "1.30.0",
49
- "@openshain/core": "0.4.0"
49
+ "@openshain/core": "0.5.0"
50
50
  },
51
51
  "devDependencies": {
52
- "@openshain/tools": "0.4.0"
52
+ "@openshain/tools": "0.5.0"
53
53
  },
54
54
  "publishConfig": {
55
55
  "access": "public"
package/src/server.ts CHANGED
@@ -10,6 +10,8 @@ import {
10
10
  type AnyEvent,
11
11
  type Artifact,
12
12
  ASK_USER,
13
+ businessDate,
14
+ companyTime,
13
15
  compileInputValidator,
14
16
  countToolCalls,
15
17
  createToolCaller,
@@ -38,6 +40,7 @@ import {
38
40
  uuidv7,
39
41
  verifyArtifact,
40
42
  type Work,
43
+ type WorkHandle,
41
44
  type WorkId,
42
45
  WorkStore,
43
46
  workHistory,
@@ -252,6 +255,7 @@ const WORK_TOOLS: Tool[] = [
252
255
  "model.completed",
253
256
  "model.failed",
254
257
  "usage.recorded",
258
+ "conversation.compacted",
255
259
  ],
256
260
  },
257
261
  payload: { type: "object" },
@@ -273,6 +277,8 @@ const RECORDABLE_TYPES: ReadonlySet<string> = new Set([
273
277
  "model.completed",
274
278
  "model.failed",
275
279
  "usage.recorded",
280
+ // The conversation is the client's to shorten: the runtime holds the events either way.
281
+ "conversation.compacted",
276
282
  ]);
277
283
 
278
284
  const SESSION_HAS_NO_TOOLS =
@@ -303,6 +309,20 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
303
309
  authority: () => authority,
304
310
  });
305
311
  const works = new WorkStore(workspaceRoot);
312
+
313
+ /**
314
+ * Opens a work for one piece of work on it, and closes it however that ends. The lock a work
315
+ * holds is the single-writer rule, so it must not outlive the call that took it; releasing it
316
+ * here means no caller can forget to.
317
+ */
318
+ async function withWork<T>(id: WorkId, use: (opened: WorkHandle) => Promise<T>): Promise<T> {
319
+ const opened = await works.open(id);
320
+ try {
321
+ return await use(opened);
322
+ } finally {
323
+ await opened.close();
324
+ }
325
+ }
306
326
  const session = new Session();
307
327
  const server = new Server(
308
328
  { name: "openshain", version: pkg.version },
@@ -369,6 +389,13 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
369
389
  const id = parseWorkId((input as { id: string }).id);
370
390
  const work = await works.get(id);
371
391
  if (isTerminal(work.status)) return failure(`work ${id} is already ${work.status}`);
392
+ // Selecting is what lets a client record into a work. Another person's conversation is
393
+ // theirs: a client that could select it could write what they never said.
394
+ if (work.principal !== config.principal.id) {
395
+ return failure(
396
+ `work ${id} belongs to ${work.principal}; a client selects only the works of the person it acts for`,
397
+ );
398
+ }
372
399
  session.select(id);
373
400
  return json({ ...work, history: workHistory(await works.events(id)) });
374
401
  }
@@ -392,17 +419,14 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
392
419
  }
393
420
  const { question } = input as { question: string };
394
421
  const callId = newCallId();
395
- const opened = await works.open(gate.id);
396
- try {
422
+ await withWork(gate.id, async (opened) => {
397
423
  await opened.append({
398
424
  type: "tool.called",
399
425
  payload: { callId, provider: RUNTIME_PROVIDER_ID, name: ASK_USER.name, input },
400
426
  });
401
427
  await opened.append({ type: "human.input_requested", payload: { callId, question } });
402
428
  await opened.transition("waiting_input", "the agent asked the person a question");
403
- } finally {
404
- await opened.close();
405
- }
429
+ });
406
430
  return json({ pending: true, call_id: callId, question });
407
431
  }
408
432
  case "work_answer": {
@@ -419,8 +443,7 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
419
443
  `no unanswered question with call_id ${callId}; pending: ${pending.map((q) => q.callId).join(", ") || "none"}`,
420
444
  );
421
445
  }
422
- const opened = await works.open(gate.id);
423
- try {
446
+ return await withWork(gate.id, async (opened) => {
424
447
  await opened.append({ type: "human.input_provided", payload: { callId, answer } });
425
448
  await opened.append({
426
449
  type: "tool.completed",
@@ -428,9 +451,7 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
428
451
  });
429
452
  await opened.transition("in_progress", "the person answered");
430
453
  return json(await opened.current());
431
- } finally {
432
- await opened.close();
433
- }
454
+ });
434
455
  }
435
456
  case "work_record": {
436
457
  const { work_id, type, payload } = input as {
@@ -454,23 +475,20 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
454
475
  if (type === "usage.recorded" && (parsed as { kind: string }).kind !== "model_inference") {
455
476
  return failure("usage.recorded from a client must have kind model_inference");
456
477
  }
457
- const opened = await works.open(id);
458
- try {
478
+ return await withWork(id, async (opened) => {
459
479
  const status = (await opened.current()).status;
460
480
  if (isTerminal(status)) return failure(`work ${id} is already ${status}`);
461
481
  const event = await opened.append({ type, payload: parsed } as never);
462
482
  return json({ id: event.id, seq: event.seq });
463
- } finally {
464
- await opened.close();
465
- }
483
+ });
466
484
  }
467
485
  case "context": {
468
486
  const now = new Date();
469
- const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
487
+ const timezone = config.company.timezone;
470
488
  const info = {
471
- now: localIso(now),
489
+ now: companyTime(timezone, now),
472
490
  timezone,
473
- business_date: localIso(now).slice(0, 10),
491
+ business_date: businessDate(timezone, now),
474
492
  workspace: workspaceRoot,
475
493
  company: config.company.name,
476
494
  principal: { id: config.principal.id, name: config.principal.name },
@@ -482,8 +500,7 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
482
500
  const current = session.current;
483
501
  if (current && !isTerminal((await works.get(current)).status)) {
484
502
  const callId = newCallId();
485
- const opened = await works.open(current);
486
- try {
503
+ await withWork(current, async (opened) => {
487
504
  await opened.append({
488
505
  type: "tool.called",
489
506
  payload: { callId, provider: RUNTIME_PROVIDER_ID, name: "context", input: {} },
@@ -492,9 +509,7 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
492
509
  type: "tool.completed",
493
510
  payload: { callId, content: [{ type: "json", value: info }], isError: false },
494
511
  });
495
- } finally {
496
- await opened.close();
497
- }
512
+ });
498
513
  }
499
514
  return result;
500
515
  }
@@ -529,8 +544,7 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
529
544
  `${by} may not decide ${approvalId}; approvers: ${approval.approvers.join(", ")}`,
530
545
  );
531
546
  }
532
- const opened = await works.open(workId);
533
- try {
547
+ return await withWork(workId, async (opened) => {
534
548
  // Under the lock: another connection may have decided this approval in between.
535
549
  if (!pendingApprovals(await opened.events()).some((a) => a.approvalId === approvalId)) {
536
550
  return failure(`approval ${approvalId} was already decided`);
@@ -564,9 +578,7 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
564
578
  work_id: workId,
565
579
  result: { content: result.content, isError: result.isError ?? false },
566
580
  });
567
- } finally {
568
- await opened.close();
569
- }
581
+ });
570
582
  }
571
583
  case "review_decide": {
572
584
  const {
@@ -636,8 +648,7 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
636
648
  }
637
649
  record = parsed.data;
638
650
  }
639
- const opened = await works.open(workId);
640
- try {
651
+ return await withWork(workId, async (opened) => {
641
652
  // Under the lock: another connection may have decided this approval in between.
642
653
  if (!pendingApprovals(await opened.events()).some((a) => a.approvalId === approvalId)) {
643
654
  return failure(`approval ${approvalId} was already decided`);
@@ -691,9 +702,7 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
691
702
  decision_file: relative(workspaceRoot, file),
692
703
  result: { content: ran.content, isError: ran.isError ?? false },
693
704
  });
694
- } finally {
695
- await opened.close();
696
- }
705
+ });
697
706
  }
698
707
  case "work_list": {
699
708
  const { works: all, problems } = await works.list();
@@ -721,7 +730,9 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
721
730
  summary: string;
722
731
  artifacts?: { path: string; sha256?: string }[];
723
732
  };
724
- const work = await complete(works, workspaceRoot, gate.id, summary, artifacts ?? []);
733
+ const work = await withWork(gate.id, (opened) =>
734
+ complete(workspaceRoot, opened, summary, artifacts ?? []),
735
+ );
725
736
  session.clear();
726
737
  return json(work);
727
738
  }
@@ -729,12 +740,9 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
729
740
  const gate = await openWork();
730
741
  if ("refused" in gate) return gate.refused;
731
742
  const { reason, detail } = input as { reason: string; detail?: string };
732
- const opened = await works.open(gate.id);
733
- try {
743
+ await withWork(gate.id, async (opened) => {
734
744
  await opened.append({ type: "work.failed", payload: { reason, detail: detail ?? "" } });
735
- } finally {
736
- await opened.close();
737
- }
745
+ });
738
746
  session.clear();
739
747
  return json(await works.get(gate.id));
740
748
  }
@@ -753,8 +761,7 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
753
761
  `work ${gate.id} is waiting for an approval; decide it with approval_decide (see approval_list) before calling tools`,
754
762
  );
755
763
  }
756
- const opened = await works.open(gate.id);
757
- try {
764
+ return await withWork(gate.id, async (opened) => {
758
765
  const limit = config.limits.maxToolCalls;
759
766
  if (countToolCalls(await opened.events()) >= limit) {
760
767
  const reason = `this work has reached its limit of ${limit} tool calls; finish it with work_complete or work_fail`;
@@ -766,9 +773,7 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
766
773
  }
767
774
  const result = await callTool(opened, { id: newCallId(), name, input });
768
775
  return toMcpResult(result);
769
- } finally {
770
- await opened.close();
771
- }
776
+ });
772
777
  }
773
778
  }
774
779
  }
@@ -797,38 +802,32 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
797
802
  * of this work wrote is marked claimed, so a reader can tell the agent's word from the record.
798
803
  */
799
804
  async function complete(
800
- works: WorkStore,
801
805
  workspaceRoot: string,
802
- id: WorkId,
806
+ opened: WorkHandle,
803
807
  summary: string,
804
808
  claimed: { path: string; sha256?: string }[],
805
809
  ): Promise<Work> {
806
810
  for (const { path } of claimed) await resolveWorkspacePath(workspaceRoot, path);
807
- const opened = await works.open(id);
808
- try {
809
- const events = await opened.events();
810
- const refs: string[] = [];
811
- const byPath = new Map<string, string>();
812
- for (const event of writesWithAfter(events)) {
813
- refs.push(event.id);
814
- for (const { path, sha256 } of event.payload.after ?? []) byPath.set(path, sha256);
815
- }
816
- const written = new Set(byPath.keys());
817
- for (const { path, sha256 } of claimed) if (!byPath.has(path)) byPath.set(path, sha256 ?? "");
818
- const artifacts: Artifact[] = [];
819
- for (const [path, reported] of byPath) {
820
- const artifact = await verifyArtifact(workspaceRoot, path, reported);
821
- artifacts.push(written.has(path) ? artifact : { ...artifact, claimed: true });
822
- }
823
- await opened.append({
824
- type: "evidence.recorded",
825
- payload: { claim: summary, refs, artifacts },
826
- });
827
- await opened.append({ type: "work.completed", payload: { summary } });
828
- return opened.current();
829
- } finally {
830
- await opened.close();
811
+ const events = await opened.events();
812
+ const refs: string[] = [];
813
+ const byPath = new Map<string, string>();
814
+ for (const event of writesWithAfter(events)) {
815
+ refs.push(event.id);
816
+ for (const { path, sha256 } of event.payload.after ?? []) byPath.set(path, sha256);
831
817
  }
818
+ const written = new Set(byPath.keys());
819
+ for (const { path, sha256 } of claimed) if (!byPath.has(path)) byPath.set(path, sha256 ?? "");
820
+ const artifacts: Artifact[] = [];
821
+ for (const [path, reported] of byPath) {
822
+ const artifact = await verifyArtifact(workspaceRoot, path, reported);
823
+ artifacts.push(written.has(path) ? artifact : { ...artifact, claimed: true });
824
+ }
825
+ await opened.append({
826
+ type: "evidence.recorded",
827
+ payload: { claim: summary, refs, artifacts },
828
+ });
829
+ await opened.append({ type: "work.completed", payload: { summary } });
830
+ return opened.current();
832
831
  }
833
832
 
834
833
  function writesWithAfter(events: AnyEvent[]): Event<"tool.completed">[] {
@@ -887,16 +886,3 @@ async function findApproval(
887
886
  }
888
887
  return undefined;
889
888
  }
890
-
891
- /** ISO 8601 with the local offset instead of Z, so the time reads as the person's clock. */
892
- function localIso(date: Date): string {
893
- const pad = (n: number) => String(n).padStart(2, "0");
894
- const offset = -date.getTimezoneOffset();
895
- const sign = offset >= 0 ? "+" : "-";
896
- const abs = Math.abs(offset);
897
- return (
898
- `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
899
- `T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}` +
900
- `${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`
901
- );
902
- }