@schoolai/shipyard 0.6.0 → 0.7.0-rc.20260221

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
@@ -12,7 +12,7 @@ import {
12
12
  PersonalRoomConnection,
13
13
  ROUTES,
14
14
  ValidationErrorResponseSchema
15
- } from "./chunk-CQBP5B4G.js";
15
+ } from "./chunk-OTIQVES4.js";
16
16
  import {
17
17
  __export,
18
18
  getShipyardHome,
@@ -11331,6 +11331,7 @@ var TaskDocumentSchema = Shape.doc({
11331
11331
  updatedAt: Shape.plain.number()
11332
11332
  }),
11333
11333
  conversation: Shape.list(MessageShape),
11334
+ pendingFollowUps: Shape.list(MessageShape),
11334
11335
  sessions: Shape.list(SessionEntryShape),
11335
11336
  diffState: DiffStateShape,
11336
11337
  plans: Shape.list(PlanVersionShape),
@@ -11338,6 +11339,7 @@ var TaskDocumentSchema = Shape.doc({
11338
11339
  diffComments: Shape.record(DiffCommentShape),
11339
11340
  planComments: Shape.record(PlanCommentShape)
11340
11341
  });
11342
+ var TERMINAL_TASK_STATES = ["completed", "failed", "canceled"];
11341
11343
  var TOOL_RISK_LEVELS = ["low", "medium", "high"];
11342
11344
  var PERMISSION_DECISIONS = ["approved", "denied"];
11343
11345
  var PermissionRequestEphemeral = Shape.plain.struct({
@@ -41672,7 +41674,8 @@ var SessionManager = class {
41672
41674
  * Determine whether to resume an existing session or start fresh.
41673
41675
  *
41674
41676
  * Walks backwards through sessions to find the most recent one with a
41675
- * non-empty agentSessionId that has not failed. If found, returns
41677
+ * non-empty agentSessionId that has not genuinely failed. Interrupted
41678
+ * (user-cancelled) sessions are resumable. If found, returns
41676
41679
  * { resume: true, sessionId } so the caller can pass it to resumeSession().
41677
41680
  */
41678
41681
  shouldResume() {
@@ -41743,7 +41746,7 @@ var SessionManager = class {
41743
41746
  });
41744
41747
  this.#inputController = controller;
41745
41748
  this.#activeQuery = response;
41746
- return this.#processMessages(response, sessionId);
41749
+ return this.#processMessages(response, sessionId, opts.abortController);
41747
41750
  }
41748
41751
  /**
41749
41752
  * Send a follow-up message to the active streaming session.
@@ -41839,9 +41842,9 @@ var SessionManager = class {
41839
41842
  });
41840
41843
  this.#inputController = controller;
41841
41844
  this.#activeQuery = response;
41842
- return this.#processMessages(response, newSessionId);
41845
+ return this.#processMessages(response, newSessionId, opts?.abortController);
41843
41846
  }
41844
- async #processMessages(response, sessionId) {
41847
+ async #processMessages(response, sessionId, abortController) {
41845
41848
  let agentSessionId = "";
41846
41849
  let lastMessageAt = Date.now();
41847
41850
  let idleTimedOut = false;
@@ -41868,14 +41871,13 @@ var SessionManager = class {
41868
41871
  }
41869
41872
  }
41870
41873
  } catch (error2) {
41871
- const errorMsg2 = idleTimedOut ? "Session idle timeout exceeded" : error2 instanceof Error ? error2.message : String(error2);
41872
- this.#markFailed(sessionId, errorMsg2);
41873
- return {
41874
+ return this.#handleProcessError(
41875
+ error2,
41874
41876
  sessionId,
41875
41877
  agentSessionId,
41876
- status: "failed",
41877
- error: errorMsg2
41878
- };
41878
+ idleTimedOut,
41879
+ abortController
41880
+ );
41879
41881
  } finally {
41880
41882
  clearInterval(idleTimer);
41881
41883
  this.#inputController = null;
@@ -42113,6 +42115,28 @@ var SessionManager = class {
42113
42115
  error: !isSuccess ? errorText ?? message.subtype : void 0
42114
42116
  };
42115
42117
  }
42118
+ #handleProcessError(error2, sessionId, agentSessionId, idleTimedOut, abortController) {
42119
+ if (abortController?.signal.aborted) {
42120
+ this.#markInterrupted(sessionId);
42121
+ return { sessionId, agentSessionId, status: "interrupted" };
42122
+ }
42123
+ const errorMsg = idleTimedOut ? "Session idle timeout exceeded" : error2 instanceof Error ? error2.message : String(error2);
42124
+ this.#markFailed(sessionId, errorMsg);
42125
+ return { sessionId, agentSessionId, status: "failed", error: errorMsg };
42126
+ }
42127
+ #markInterrupted(sessionId) {
42128
+ const idx = this.#findSessionIndex(sessionId);
42129
+ change(this.#taskDoc, (draft) => {
42130
+ const session = idx >= 0 ? draft.sessions.get(idx) : void 0;
42131
+ if (session) {
42132
+ session.status = "interrupted";
42133
+ session.completedAt = Date.now();
42134
+ }
42135
+ draft.meta.status = "canceled";
42136
+ draft.meta.updatedAt = Date.now();
42137
+ });
42138
+ this.#notifyStatusChange("canceled");
42139
+ }
42116
42140
  #markFailed(sessionId, errorMsg) {
42117
42141
  const idx = this.#findSessionIndex(sessionId);
42118
42142
  change(this.#taskDoc, (draft) => {
@@ -42488,6 +42512,34 @@ var TaskEphemeralDeclarations = {
42488
42512
  permReqs: PermissionRequestEphemeral,
42489
42513
  permResps: PermissionResponseEphemeral
42490
42514
  };
42515
+ var TERMINAL_STATUSES = new Set(TERMINAL_TASK_STATES);
42516
+ async function rehydrateTaskDocuments(roomHandle, roomDoc, repo, log) {
42517
+ try {
42518
+ await roomHandle.waitForSync({ kind: "storage", timeout: 5e3 });
42519
+ } catch {
42520
+ log.warn(
42521
+ "Room doc storage sync timed out during rehydration \u2014 task rehydration may be incomplete"
42522
+ );
42523
+ }
42524
+ const roomJson = roomDoc.toJSON();
42525
+ const taskEntries = Object.entries(roomJson.taskIndex ?? {});
42526
+ log.info({ count: taskEntries.length }, "Rehydrating task documents from storage");
42527
+ for (const [taskId, entry] of taskEntries) {
42528
+ if (TERMINAL_STATUSES.has(entry.status)) continue;
42529
+ try {
42530
+ const taskDocId = buildDocumentId("task", taskId, DEFAULT_EPOCH);
42531
+ const taskHandle = repo.get(taskDocId, TaskDocumentSchema, TaskEphemeralDeclarations);
42532
+ await taskHandle.waitForSync({ kind: "storage", timeout: 5e3 });
42533
+ if (recoverOrphanedTask(taskHandle.doc, createChildLogger({ mode: "rehydrate", taskId }))) {
42534
+ updateTaskInIndex(roomDoc, taskId, { status: "failed", updatedAt: Date.now() });
42535
+ }
42536
+ log.debug({ taskId, taskDocId }, "Task document rehydrated");
42537
+ } catch (err) {
42538
+ log.warn({ taskId, err }, "Failed to rehydrate task document");
42539
+ }
42540
+ }
42541
+ log.info("Task document rehydration complete");
42542
+ }
42491
42543
  async function serve(env) {
42492
42544
  if (!env.SHIPYARD_SIGNALING_URL) {
42493
42545
  logger.error("SHIPYARD_SIGNALING_URL is required for serve mode");
@@ -42520,6 +42572,7 @@ async function serve(env) {
42520
42572
  const activeTasks = /* @__PURE__ */ new Map();
42521
42573
  const watchedTasks = /* @__PURE__ */ new Map();
42522
42574
  const taskHandles = /* @__PURE__ */ new Map();
42575
+ const lastProcessedConvLen = /* @__PURE__ */ new Map();
42523
42576
  const devSuffix = env.SHIPYARD_DEV ? "-dev" : "";
42524
42577
  const machineId = env.SHIPYARD_MACHINE_ID ?? `${hostname2()}${devSuffix}`;
42525
42578
  const dataDir = resolve(env.SHIPYARD_DATA_DIR.replace("~", homedir2()));
@@ -42733,6 +42786,7 @@ async function serve(env) {
42733
42786
  const typedRoomHandle = roomHandle;
42734
42787
  const typedRoomDoc = roomHandle.doc;
42735
42788
  cleanupStaleSetupEntries(typedRoomDoc, machineId, log);
42789
+ await rehydrateTaskDocuments(roomHandle, typedRoomDoc, repo, log);
42736
42790
  typedRoomHandle.enhancePromptReqs.subscribe(({ key: requestId, value, source }) => {
42737
42791
  if (source !== "remote") return;
42738
42792
  if (!value) return;
@@ -42904,6 +42958,7 @@ async function serve(env) {
42904
42958
  activeTasks,
42905
42959
  watchedTasks,
42906
42960
  taskHandles,
42961
+ lastProcessedConvLen,
42907
42962
  peerManager,
42908
42963
  env,
42909
42964
  machineId,
@@ -42935,6 +42990,7 @@ async function serve(env) {
42935
42990
  }
42936
42991
  watchedTasks.clear();
42937
42992
  taskHandles.clear();
42993
+ lastProcessedConvLen.clear();
42938
42994
  for (const timer of pendingCleanupTimers) clearTimeout(timer);
42939
42995
  pendingCleanupTimers.clear();
42940
42996
  for (const [id, ptyMgr] of terminalPtys) {
@@ -43109,6 +43165,12 @@ function handleMessage(msg, ctx) {
43109
43165
  case "worktree-create-error":
43110
43166
  ctx.log.debug({ type: msg.type }, "Worktree create echo");
43111
43167
  break;
43168
+ case "cancel-task":
43169
+ handleCancelTask(msg, ctx);
43170
+ break;
43171
+ case "control-ack":
43172
+ ctx.log.debug({ type: msg.type }, "Control ack echo");
43173
+ break;
43112
43174
  case "authenticated":
43113
43175
  case "agent-joined":
43114
43176
  case "agent-left":
@@ -43160,6 +43222,32 @@ function handleNotifyTask(msg, ctx) {
43160
43222
  taskLog.error({ err: errMsg }, "Failed to start watching task document");
43161
43223
  });
43162
43224
  }
43225
+ function handleCancelTask(msg, ctx) {
43226
+ const { taskId, requestId } = msg;
43227
+ const taskLog = createChildLogger({ mode: "serve", taskId });
43228
+ const activeTask = ctx.activeTasks.get(taskId);
43229
+ if (!activeTask) {
43230
+ taskLog.warn("Cancel requested but no active task found");
43231
+ ctx.connection.send({
43232
+ type: "control-ack",
43233
+ requestId,
43234
+ taskId,
43235
+ action: "cancel",
43236
+ accepted: false,
43237
+ error: "No active agent running for this task"
43238
+ });
43239
+ return;
43240
+ }
43241
+ taskLog.info("Canceling active task");
43242
+ activeTask.abortController.abort();
43243
+ ctx.connection.send({
43244
+ type: "control-ack",
43245
+ requestId,
43246
+ taskId,
43247
+ action: "cancel",
43248
+ accepted: true
43249
+ });
43250
+ }
43163
43251
  var ENHANCE_PROMPT_TIMEOUT_MS = 3e4;
43164
43252
  var ENHANCE_SYSTEM_PROMPT = `You are a prompt rewriter that transforms rough user requests into clear, specific instructions for an AI coding assistant (Claude Code). The enhanced prompt will be sent AS the user's message to that assistant.
43165
43253
 
@@ -43586,12 +43674,15 @@ async function watchTaskDocument(taskId, taskLog, ctx) {
43586
43674
  try {
43587
43675
  await taskHandle.waitForSync({ kind: "storage", timeout: 5e3 });
43588
43676
  } catch {
43589
- taskLog.debug({ taskDocId }, "No existing task data in storage");
43677
+ taskLog.info({ taskDocId }, "No existing task data in storage");
43590
43678
  }
43591
43679
  try {
43592
43680
  await taskHandle.waitForSync({ kind: "network", timeout: 3e3 });
43593
43681
  } catch {
43594
- taskLog.debug({ taskDocId }, "Network sync timed out (browser may not be connected yet)");
43682
+ taskLog.warn(
43683
+ { taskDocId, timeoutMs: 3e3 },
43684
+ "Network sync timed out (browser may not be connected yet)"
43685
+ );
43595
43686
  }
43596
43687
  if (recoverOrphanedTask(taskHandle.doc, taskLog)) {
43597
43688
  updateTaskInIndex(ctx.roomDoc, taskId, { status: "failed", updatedAt: Date.now() });
@@ -43622,27 +43713,38 @@ async function watchTaskDocument(taskId, taskLog, ctx) {
43622
43713
  onTaskDocChanged(taskId, taskHandle, taskLog, ctx);
43623
43714
  }
43624
43715
  }
43625
- function handleFollowUp(activeTask, conversationLen, taskLog) {
43716
+ function promotePendingFollowUps(taskHandle, activeTask, taskLog) {
43626
43717
  if (!activeTask) return;
43627
- if (conversationLen <= activeTask.lastDispatchedConvLen) {
43628
- taskLog.debug(
43629
- { conversationLen, lastDispatched: activeTask.lastDispatchedConvLen },
43630
- "Conversation unchanged since last dispatch, skipping duplicate"
43631
- );
43718
+ if (activeTask.abortController.signal.aborted) {
43719
+ taskLog.debug("Task is being aborted, skipping pending follow-up promotion");
43632
43720
  return;
43633
43721
  }
43634
43722
  if (!activeTask.sessionManager.isStreaming) {
43635
- taskLog.debug("Task already running but not streaming, skipping");
43723
+ taskLog.debug("Task not streaming, skipping pending follow-up promotion");
43636
43724
  return;
43637
43725
  }
43638
- const contentBlocks = activeTask.sessionManager.getLatestUserContentBlocks();
43639
- if (contentBlocks && contentBlocks.length > 0) {
43726
+ const json = taskHandle.doc.toJSON();
43727
+ const pending = json.pendingFollowUps ?? [];
43728
+ if (pending.length === 0) return;
43729
+ change(taskHandle.doc, (draft) => {
43730
+ const items = draft.pendingFollowUps.toArray();
43731
+ for (const msg of items) {
43732
+ draft.conversation.push(msg);
43733
+ }
43734
+ if (draft.pendingFollowUps.length > 0) {
43735
+ draft.pendingFollowUps.delete(0, draft.pendingFollowUps.length);
43736
+ }
43737
+ });
43738
+ const allContentBlocks = pending.flatMap(
43739
+ (msg) => msg.content.filter((block2) => block2.type === "text" || block2.type === "image")
43740
+ );
43741
+ if (allContentBlocks.length > 0) {
43640
43742
  try {
43641
- taskLog.info("Sending follow-up to active streaming session");
43642
- activeTask.lastDispatchedConvLen = conversationLen;
43643
- activeTask.sessionManager.sendFollowUp(contentBlocks);
43743
+ taskLog.info({ pendingCount: pending.length }, "Promoted pending follow-ups to conversation");
43744
+ activeTask.lastDispatchedConvLen = json.conversation.length + pending.length;
43745
+ activeTask.sessionManager.sendFollowUp(allContentBlocks);
43644
43746
  } catch (err) {
43645
- taskLog.warn({ err }, "Failed to send follow-up to streaming session");
43747
+ taskLog.warn({ err }, "Failed to send promoted follow-up");
43646
43748
  }
43647
43749
  }
43648
43750
  }
@@ -43659,22 +43761,35 @@ function onTaskDocChanged(taskId, taskHandle, taskLog, ctx) {
43659
43761
  "onTaskDocChanged evaluation"
43660
43762
  );
43661
43763
  if (ctx.activeTasks.has(taskId)) {
43662
- const conversation2 = json.conversation;
43663
- const lastMessage2 = conversation2[conversation2.length - 1];
43664
- if (lastMessage2?.role === "user") {
43665
- handleFollowUp(ctx.activeTasks.get(taskId), conversation2.length, taskLog);
43764
+ const pendingFollowUps2 = json.pendingFollowUps ?? [];
43765
+ if (pendingFollowUps2.length > 0) {
43766
+ promotePendingFollowUps(taskHandle, ctx.activeTasks.get(taskId), taskLog);
43666
43767
  }
43768
+ const conversation2 = json.conversation;
43667
43769
  const activeLastUserMsg = [...conversation2].reverse().find((m) => m.role === "user");
43668
43770
  const activeCwd = activeLastUserMsg?.cwd ?? process.cwd();
43669
43771
  debouncedDiffCapture(taskId, activeCwd, taskHandle, taskLog);
43670
43772
  debouncedBranchDiffCapture(taskId, activeCwd, taskHandle, taskLog);
43671
43773
  return;
43672
43774
  }
43673
- if (json.meta.status === "working" || json.meta.status === "input-required" || json.meta.status === "starting") {
43674
- taskLog.debug({ status: json.meta.status }, "Status blocks new work, skipping");
43675
- return;
43775
+ const pendingFollowUps = json.pendingFollowUps ?? [];
43776
+ if (pendingFollowUps.length > 0) {
43777
+ taskLog.info(
43778
+ { pendingCount: pendingFollowUps.length },
43779
+ "Promoting orphaned pending follow-ups"
43780
+ );
43781
+ change(taskHandle.doc, (draft) => {
43782
+ const items = draft.pendingFollowUps.toArray();
43783
+ for (const msg of items) {
43784
+ draft.conversation.push(msg);
43785
+ }
43786
+ if (draft.pendingFollowUps.length > 0) {
43787
+ draft.pendingFollowUps.delete(0, draft.pendingFollowUps.length);
43788
+ }
43789
+ });
43676
43790
  }
43677
- const conversation = json.conversation;
43791
+ const freshJson = taskHandle.doc.toJSON();
43792
+ const conversation = freshJson.conversation;
43678
43793
  if (conversation.length === 0) {
43679
43794
  taskLog.debug("No conversation messages, skipping");
43680
43795
  return;
@@ -43683,7 +43798,18 @@ function onTaskDocChanged(taskId, taskHandle, taskLog, ctx) {
43683
43798
  if (!lastMessage || lastMessage.role !== "user") {
43684
43799
  return;
43685
43800
  }
43686
- taskLog.info("New user message detected, starting agent");
43801
+ const prevLen = ctx.lastProcessedConvLen.get(taskId) ?? 0;
43802
+ if (conversation.length <= prevLen) {
43803
+ taskLog.debug(
43804
+ { conversationLen: conversation.length, lastProcessed: prevLen },
43805
+ "No new messages since last run, skipping"
43806
+ );
43807
+ return;
43808
+ }
43809
+ taskLog.info(
43810
+ { prevLen, newLen: conversation.length },
43811
+ "New user message detected, starting agent"
43812
+ );
43687
43813
  const cwd = lastMessage.cwd ?? process.cwd();
43688
43814
  const model = lastMessage.model ?? void 0;
43689
43815
  const permissionMode = mapPermissionMode(lastMessage.permissionMode);
@@ -43700,6 +43826,7 @@ function onTaskDocChanged(taskId, taskHandle, taskLog, ctx) {
43700
43826
  lastDispatchedConvLen: conversation.length
43701
43827
  };
43702
43828
  ctx.activeTasks.set(taskId, activeTask);
43829
+ ctx.lastProcessedConvLen.set(taskId, conversation.length);
43703
43830
  ctx.signaling.updateStatus("running", taskId);
43704
43831
  const turnStartRefPromise = captureTreeSnapshot(cwd);
43705
43832
  turnStartRefPromise.then((ref) => taskLog.debug({ turnStartRef: ref }, "Captured turn start snapshot")).catch((err) => taskLog.warn({ err }, "Failed to capture turn start snapshot"));
@@ -43746,6 +43873,7 @@ async function cleanupTaskRun(opts) {
43746
43873
  const { taskId, cwd, taskHandle, taskLog, turnStartRefPromise, abortController, ctx } = opts;
43747
43874
  const activeTask = ctx.activeTasks.get(taskId);
43748
43875
  activeTask?.sessionManager.closeSession();
43876
+ abortController.abort();
43749
43877
  clearDebouncedTimer(diffDebounceTimers, taskId);
43750
43878
  clearDebouncedTimer(branchDiffTimers, taskId);
43751
43879
  try {
@@ -43764,7 +43892,6 @@ async function cleanupTaskRun(opts) {
43764
43892
  } catch (err) {
43765
43893
  taskLog.warn({ err }, "Failed to capture turn diff");
43766
43894
  }
43767
- abortController.abort();
43768
43895
  for (const [key] of taskHandle.permReqs.getAll()) {
43769
43896
  taskHandle.permReqs.delete(key);
43770
43897
  }
@@ -43773,6 +43900,7 @@ async function cleanupTaskRun(opts) {
43773
43900
  }
43774
43901
  ctx.activeTasks.delete(taskId);
43775
43902
  ctx.signaling.updateStatus("idle");
43903
+ onTaskDocChanged(taskId, taskHandle, taskLog, ctx);
43776
43904
  }
43777
43905
  function clearDebouncedTimer(timers, taskId) {
43778
43906
  const timer = timers.get(taskId);
@@ -44154,7 +44282,7 @@ function handleResult(log, result, startTime) {
44154
44282
  async function handleSubcommand() {
44155
44283
  const subcommand = process.argv[2];
44156
44284
  if (subcommand === "login") {
44157
- const { loginCommand } = await import("./login-625HP2EN.js");
44285
+ const { loginCommand } = await import("./login-6TA7RIBE.js");
44158
44286
  const hasCheck = process.argv.includes("--check");
44159
44287
  await loginCommand({ check: hasCheck });
44160
44288
  return true;