@schoolai/shipyard 3.2.3-rc.20260422.1 → 3.2.3-rc.20260422.2

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/index.js CHANGED
@@ -106,7 +106,7 @@ async function handleSubcommand() {
106
106
  return true;
107
107
  }
108
108
  if (subcommand === "start") {
109
- const { startCommand } = await import("./start-FONQGWYX.js");
109
+ const { startCommand } = await import("./start-4KHICMP2.js");
110
110
  await startCommand();
111
111
  return true;
112
112
  }
@@ -123,7 +123,7 @@ async function main() {
123
123
  const args = parseCliArgs();
124
124
  if (args.serve) {
125
125
  await loadAuthFromConfig(env);
126
- const { serve } = await import("./serve-KNOYKFPQ.js");
126
+ const { serve } = await import("./serve-3FTGY4YL.js");
127
127
  return serve({ isDev: env.SHIPYARD_DEV });
128
128
  }
129
129
  logger.error("Use `shipyard start` to run the daemon. Use --help for usage.");
@@ -87601,6 +87601,47 @@ ${conversationReplay}` : conversationReplay;
87601
87601
  }
87602
87602
  });
87603
87603
  }
87604
+ /**
87605
+ * Deduped append for echoed user messages. Browser-originated writes carry
87606
+ * a correlationId so dedup catches the twin row via `corr:<id>`. Synthetic
87607
+ * pushes (plan injection, side threads, model-set) have no correlationId —
87608
+ * we pass `event.sdkUuid` so the `sdk:<uuid>` pass catches SDK replays
87609
+ * instead of falling back to the 60s fingerprint window, which would
87610
+ * wrongly collapse two distinct synthetic pushes with identical content.
87611
+ */
87612
+ async #handleUserMessageEcho(event) {
87613
+ const echoMsgId = crypto.randomUUID();
87614
+ const meta = this.#callbacks.onBeforeStoreUserMessage?.(event);
87615
+ const result = await this.#config.store.appendMessageDeduped(
87616
+ {
87617
+ channelId: this.#config.channelId,
87618
+ messageId: echoMsgId,
87619
+ participantId: meta?.participantId ?? this.#config.humanParticipantId ?? PENDING_AGENT_PARTICIPANT_ID,
87620
+ senderKind: "human",
87621
+ content: meta?.content ?? event.content,
87622
+ timestamp: Date.now(),
87623
+ model: meta?.model ?? null,
87624
+ reasoningEffort: meta?.reasoningEffort ?? null,
87625
+ permissionMode: meta?.permissionMode ?? null,
87626
+ ...meta?.isSynthetic && { isSynthetic: true },
87627
+ ...meta?.correlationId && { correlationId: meta.correlationId },
87628
+ ...event.sdkUuid && { sdkUuid: event.sdkUuid }
87629
+ },
87630
+ {}
87631
+ );
87632
+ if (result.isDuplicate) {
87633
+ this.#config.log({
87634
+ event: "thread_echo_dedup_hit",
87635
+ threadId: this.#config.threadId,
87636
+ dedupKey: result.dedupKey,
87637
+ seqNo: result.seqNo
87638
+ });
87639
+ }
87640
+ this.#callbacks.onMessageStored?.(result.seqNo, echoMsgId, "human", event.sdkUuid);
87641
+ if (meta?.correlationId) {
87642
+ this.#callbacks.onUserMessageConfirmed?.(meta.correlationId, event.sdkUuid);
87643
+ }
87644
+ }
87604
87645
  async #handleSubprocessEvent(event) {
87605
87646
  switch (event.type) {
87606
87647
  case "init_received":
@@ -87655,25 +87696,7 @@ ${conversationReplay}` : conversationReplay;
87655
87696
  case "rate_limit_error":
87656
87697
  break;
87657
87698
  case "user_message_echo": {
87658
- const echoMsgId = crypto.randomUUID();
87659
- const meta = this.#callbacks.onBeforeStoreUserMessage?.(event);
87660
- const echoSeqNo = await this.#config.store.appendMessage({
87661
- channelId: this.#config.channelId,
87662
- messageId: echoMsgId,
87663
- participantId: meta?.participantId ?? this.#config.humanParticipantId ?? PENDING_AGENT_PARTICIPANT_ID,
87664
- senderKind: "human",
87665
- content: meta?.content ?? event.content,
87666
- timestamp: Date.now(),
87667
- model: meta?.model ?? null,
87668
- reasoningEffort: meta?.reasoningEffort ?? null,
87669
- permissionMode: meta?.permissionMode ?? null,
87670
- ...meta?.isSynthetic && { isSynthetic: true },
87671
- ...meta?.correlationId && { correlationId: meta.correlationId }
87672
- });
87673
- this.#callbacks.onMessageStored?.(echoSeqNo, echoMsgId, "human", event.sdkUuid);
87674
- if (meta?.correlationId) {
87675
- this.#callbacks.onUserMessageConfirmed?.(meta.correlationId, event.sdkUuid);
87676
- }
87699
+ await this.#handleUserMessageEcho(event);
87677
87700
  break;
87678
87701
  }
87679
87702
  case "assistant_message": {
@@ -89250,6 +89273,16 @@ var ResourcePushManager = class {
89250
89273
  #batchTimer;
89251
89274
  #pushCountThisTurn = 0;
89252
89275
  #pushedThisTurn = /* @__PURE__ */ new Set();
89276
+ /**
89277
+ * Ref-counted session-lifetime claims. Each `markPushed` increments;
89278
+ * each `unmarkPushed` decrements. Entries are deleted when the count
89279
+ * hits zero. Used for "inject exactly once per subprocess session"
89280
+ * callers (the task-list prepend) where two concurrent paths (sync
89281
+ * prepend + async push pipeline) may both legitimately claim the URI.
89282
+ * A plain Set would let an optimistic claim's rollback erase another
89283
+ * caller's successful mark; the ref count prevents that.
89284
+ */
89285
+ #pushedEverCount = /* @__PURE__ */ new Map();
89253
89286
  #disposed = false;
89254
89287
  #epoch = 0;
89255
89288
  #flushInProgress = Promise.resolve();
@@ -89302,12 +89335,46 @@ var ResourcePushManager = class {
89302
89335
  return this.#pushedThisTurn;
89303
89336
  }
89304
89337
  /**
89305
- * Mark a URI as already pushed this turn (e.g. when injected
89306
- * synchronously in handleBeforeSpawn rather than via the async
89307
- * push pipeline).
89338
+ * Whether a URI has been pushed at least once this session. For one-shot
89339
+ * callers (task-list prepend) to decide whether to inject again.
89340
+ */
89341
+ isSessionPushed(uri) {
89342
+ return this.#pushedEverCount.has(uri);
89343
+ }
89344
+ /**
89345
+ * Seed the session-lifetime claim for URIs already pushed in a prior
89346
+ * daemon run (resumable_idle tasks whose JSONL already contains a
89347
+ * prepend synthetic). Without this, restart re-injects the task-list
89348
+ * because the fresh push manager has an empty count map.
89349
+ */
89350
+ seedSessionPushed(uri) {
89351
+ if (!this.#pushedEverCount.has(uri)) this.#pushedEverCount.set(uri, 1);
89352
+ }
89353
+ /**
89354
+ * Mark a URI as pushed this turn AND record a session-lifetime claim.
89355
+ * Call from both the sync prepend (before awaiting resolve) and the
89356
+ * async push pipeline (after a successful write). Pair each call with
89357
+ * `unmarkPushed` only if the operation did NOT ultimately write.
89308
89358
  */
89309
89359
  markPushed(uri) {
89310
89360
  this.#pushedThisTurn.add(uri);
89361
+ this.#pushedEverCount.set(uri, (this.#pushedEverCount.get(uri) ?? 0) + 1);
89362
+ }
89363
+ /**
89364
+ * Release a `markPushed` claim. Decrements the session-lifetime count —
89365
+ * the entry is only removed when the count hits zero, so a concurrent
89366
+ * caller's successful write stays recorded even if this caller rolls
89367
+ * back. Also clears the per-turn set unconditionally (a successful
89368
+ * parallel caller re-adds it as needed before its own write).
89369
+ */
89370
+ unmarkPushed(uri) {
89371
+ this.#pushedThisTurn.delete(uri);
89372
+ const current2 = this.#pushedEverCount.get(uri) ?? 0;
89373
+ if (current2 <= 1) {
89374
+ this.#pushedEverCount.delete(uri);
89375
+ } else {
89376
+ this.#pushedEverCount.set(uri, current2 - 1);
89377
+ }
89311
89378
  }
89312
89379
  /** Permanently shut down. Use reset() for clearSession. */
89313
89380
  dispose() {
@@ -89329,6 +89396,7 @@ var ResourcePushManager = class {
89329
89396
  this.#subscriptions.clear();
89330
89397
  this.#dirtyUris.clear();
89331
89398
  this.#pushedThisTurn.clear();
89399
+ this.#pushedEverCount.clear();
89332
89400
  this.#pushCountThisTurn = 0;
89333
89401
  }
89334
89402
  #markDirty(uri) {
@@ -89439,35 +89507,11 @@ var ResourcePushManager = class {
89439
89507
  this.#batchTimer.reset();
89440
89508
  const batch = this.#drainDirtyBatch();
89441
89509
  if (!batch) return;
89510
+ const markedUris = /* @__PURE__ */ new Set();
89442
89511
  try {
89443
- const resolved = await this.#resolveBatch(batch);
89444
- if (this.#disposed || this.#epoch !== startEpoch) return;
89445
- if (resolved.size === 0) {
89446
- if (this.#dirtyUris.size > 0) this.#batchTimer.schedule();
89447
- return;
89448
- }
89449
- const plan = await this.#planPush(resolved);
89450
- if (this.#disposed || this.#epoch !== startEpoch || plan.syntheticMessages.length === 0)
89451
- return;
89452
- const allContent = await this.#writeSynthetics(plan.syntheticMessages);
89453
- if (this.#disposed || this.#epoch !== startEpoch) return;
89454
- this.#deliverToSubprocess(allContent, resolved.size);
89455
- for (const uri of resolved.keys()) {
89456
- this.#pushedThisTurn.add(uri);
89457
- }
89458
- this.#deps.log({
89459
- event: "resource_push_delivered",
89460
- taskId: this.#deps.taskId,
89461
- uriCount: resolved.size
89462
- });
89463
- this.#deps.log({
89464
- event: "resource_push_flushed",
89465
- taskId: this.#deps.taskId,
89466
- uriCount: batch.size,
89467
- syntheticCount: plan.syntheticMessages.length,
89468
- pushCountThisTurn: this.#pushCountThisTurn
89469
- });
89512
+ await this.#executeFlush(batch, startEpoch, markedUris);
89470
89513
  } catch (err) {
89514
+ for (const uri of markedUris) this.unmarkPushed(uri);
89471
89515
  this.#requeueBatch(batch);
89472
89516
  this.#deps.log({
89473
89517
  event: "resource_push_flush_error",
@@ -89477,6 +89521,30 @@ var ResourcePushManager = class {
89477
89521
  });
89478
89522
  }
89479
89523
  }
89524
+ async #executeFlush(batch, startEpoch, markedUris) {
89525
+ const resolved = await this.#resolveBatch(batch);
89526
+ if (this.#disposed || this.#epoch !== startEpoch) return;
89527
+ if (resolved.size === 0) {
89528
+ if (this.#dirtyUris.size > 0) this.#batchTimer.schedule();
89529
+ return;
89530
+ }
89531
+ const plan = await this.#planPush(resolved);
89532
+ if (this.#disposed || this.#epoch !== startEpoch || plan.syntheticMessages.length === 0) return;
89533
+ for (const uri of resolved.keys()) {
89534
+ this.markPushed(uri);
89535
+ markedUris.add(uri);
89536
+ }
89537
+ const allContent = await this.#writeSynthetics(plan.syntheticMessages);
89538
+ if (this.#disposed || this.#epoch !== startEpoch) return;
89539
+ this.#deliverToSubprocess(allContent, resolved.size);
89540
+ this.#deps.log({
89541
+ event: "resource_push_flushed",
89542
+ taskId: this.#deps.taskId,
89543
+ uriCount: batch.size,
89544
+ syntheticCount: plan.syntheticMessages.length,
89545
+ pushCountThisTurn: this.#pushCountThisTurn
89546
+ });
89547
+ }
89480
89548
  };
89481
89549
 
89482
89550
  // src/services/rewind.ts
@@ -89625,10 +89693,10 @@ var RewindCheckpointHandler = class {
89625
89693
  this.#allCheckpointTurnNos = [...allTurnNos];
89626
89694
  }
89627
89695
  recordSeqNo(seqNo) {
89628
- this.#latestSeqNo = seqNo;
89696
+ if (seqNo > this.#latestSeqNo) this.#latestSeqNo = seqNo;
89629
89697
  }
89630
89698
  recordSdkUuid(seqNo, sdkUuid) {
89631
- this.#seqNoToSdkUuid.set(seqNo, sdkUuid);
89699
+ if (!this.#seqNoToSdkUuid.has(seqNo)) this.#seqNoToSdkUuid.set(seqNo, sdkUuid);
89632
89700
  }
89633
89701
  findSdkUuidForSeqNo(targetSeqNo) {
89634
89702
  let bestUuid = null;
@@ -92459,6 +92527,12 @@ function classifyRoiTransition(nextStatus, roiEverRan) {
92459
92527
  if (nextStatus === "in_progress") return "reset-cycle";
92460
92528
  return "nothing";
92461
92529
  }
92530
+ function isTaskListSynthetic(msg, taskUri) {
92531
+ if (!msg.isSynthetic || msg.content.length !== 1) return false;
92532
+ const block2 = msg.content[0];
92533
+ if (!block2 || block2.type !== "resource") return false;
92534
+ return "uri" in block2.resource && block2.resource.uri === taskUri;
92535
+ }
92462
92536
  var Task = class {
92463
92537
  #deps;
92464
92538
  #mainThread;
@@ -92594,6 +92668,8 @@ var Task = class {
92594
92668
  error: err instanceof Error ? err.message : String(err)
92595
92669
  });
92596
92670
  });
92671
+ const seedPromise = this.#seedPushManagerFromHistory();
92672
+ this.#hydrationPromise = this.#hydrationPromise ? this.#hydrationPromise.then(() => seedPromise) : seedPromise;
92597
92673
  }
92598
92674
  this.#subagentManager = new SubagentManager({
92599
92675
  taskId: deps.taskId,
@@ -93610,6 +93686,33 @@ var Task = class {
93610
93686
  if (!arr || arr.length === 0) return void 0;
93611
93687
  return arr.shift();
93612
93688
  }
93689
+ /**
93690
+ * Resume path: if a prior daemon run already injected the task-list for
93691
+ * this channel, its synthetic row is in the JSONL. Seed the push manager
93692
+ * so `#resolveTaskListPrepend`'s `isSessionPushed` guard matches on the
93693
+ * first spawn — otherwise restart re-prepends, duplicating the chip and
93694
+ * dumping the full resource text back into the agent's context.
93695
+ *
93696
+ * Chained into `#hydrationPromise` (which `#awaitHydration` blocks on at
93697
+ * every spawn/flush entry point) so the seed completes before any
93698
+ * caller reaches `#resolveTaskListPrepend`. A fire-and-forget scan would
93699
+ * race against a fast-arriving first user message.
93700
+ */
93701
+ async #seedPushManagerFromHistory() {
93702
+ const taskUri = buildTaskResourceUri(this.#deps.taskId);
93703
+ try {
93704
+ const msgs = await this.#deps.store.getMessages(this.#deps.channelId);
93705
+ if (msgs.some((msg) => isTaskListSynthetic(msg, taskUri))) {
93706
+ this.#pushManager.seedSessionPushed(taskUri);
93707
+ }
93708
+ } catch (err) {
93709
+ this.#deps.log({
93710
+ event: "push_manager_seed_failed",
93711
+ taskId: this.#deps.taskId,
93712
+ error: err instanceof Error ? err.message : String(err)
93713
+ });
93714
+ }
93715
+ }
93613
93716
  async #awaitHydration() {
93614
93717
  if (this.#hydrationPromise) {
93615
93718
  await this.#hydrationPromise;
@@ -93730,10 +93833,12 @@ Use this context to maintain continuity. You have already done this work \u2014
93730
93833
  const registry = this.#deps.resourceRegistry;
93731
93834
  if (!registry) return null;
93732
93835
  const taskUri = buildTaskResourceUri(this.#deps.taskId);
93733
- if (this.#pushManager.getPushedUris().has(taskUri)) return null;
93836
+ if (this.#pushManager.isSessionPushed(taskUri)) return null;
93837
+ this.#pushManager.markPushed(taskUri);
93838
+ let injected = false;
93734
93839
  try {
93735
93840
  const taskResource = await registry.resolve(taskUri);
93736
- if ("text" in taskResource && !taskResource.text.includes('task-count="0"')) {
93841
+ if ("text" in taskResource && !taskResource.text.includes('<task-list task-count="0"')) {
93737
93842
  const rootMsg = buildRootMessage(
93738
93843
  taskUri,
93739
93844
  taskResource,
@@ -93748,10 +93853,12 @@ Use this context to maintain continuity. You have already done this work \u2014
93748
93853
  },
93749
93854
  [rootMsg]
93750
93855
  );
93751
- this.#pushManager.markPushed(taskUri);
93856
+ injected = true;
93752
93857
  return rootMsg.content;
93753
93858
  }
93754
93859
  } catch {
93860
+ } finally {
93861
+ if (!injected) this.#pushManager.unmarkPushed(taskUri);
93755
93862
  }
93756
93863
  return null;
93757
93864
  }
@@ -94724,14 +94831,23 @@ function findSdkUuidForSeqNoInTask(tasks, taskId, seqNo) {
94724
94831
  // src/services/task/manager/task-manager-template.ts
94725
94832
  function buildInitialOverlayFromTemplate(template, now) {
94726
94833
  if (!template || template.items.length === 0) return void 0;
94727
- const userTasks = template.items.map((item2) => ({
94728
- id: item2.id,
94834
+ const idRemap = new Map(template.items.map((item2, index) => [item2.id, String(index + 1)]));
94835
+ const userTasks = template.items.map((item2, index) => ({
94836
+ id: String(index + 1),
94729
94837
  subject: item2.content,
94730
94838
  description: item2.description,
94731
94839
  status: "pending",
94732
94840
  owner: "user",
94733
94841
  blocks: [],
94734
- blockedBy: item2.deps,
94842
+ /**
94843
+ * Drop deps pointing outside the template. Falling through the
94844
+ * original colon-bearing ID would leave a dangling `blockedBy` that
94845
+ * CC's TaskUpdate can't resolve — silently stuck in the UI.
94846
+ */
94847
+ blockedBy: item2.deps.flatMap((dep) => {
94848
+ const mapped = idRemap.get(dep);
94849
+ return mapped ? [mapped] : [];
94850
+ }),
94735
94851
  createdAt: now,
94736
94852
  updatedAt: now
94737
94853
  }));
@@ -105833,4 +105949,4 @@ export {
105833
105949
  _testing,
105834
105950
  serve
105835
105951
  };
105836
- //# sourceMappingURL=serve-KNOYKFPQ.js.map
105952
+ //# sourceMappingURL=serve-3FTGY4YL.js.map