@schoolai/shipyard 0.1.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/index.js CHANGED
@@ -1,15 +1,25 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ AuthGitHubCallbackRequestSchema,
4
+ AuthGitHubCallbackResponseSchema,
5
+ AuthVerifyResponseSchema,
6
+ BRANCH_NAME_PATTERN,
7
+ CollabCreateRequestSchema,
8
+ CollabCreateResponseSchema,
9
+ ErrorResponseSchema,
10
+ HealthResponseSchema,
3
11
  PermissionModeSchema,
4
12
  PersonalRoomConnection,
5
- ROUTES
6
- } from "./chunk-HOG3H5HO.js";
13
+ ROUTES,
14
+ ValidationErrorResponseSchema
15
+ } from "./chunk-CQBP5B4G.js";
7
16
  import {
17
+ getShipyardHome,
8
18
  validateEnv
9
- } from "./chunk-WB5DGKI3.js";
19
+ } from "./chunk-6S523TH3.js";
10
20
 
11
21
  // src/index.ts
12
- import { mkdir as mkdir3 } from "fs/promises";
22
+ import { mkdir as mkdir4 } from "fs/promises";
13
23
  import { homedir as homedir3 } from "os";
14
24
  import { resolve as resolve2 } from "path";
15
25
  import { parseArgs } from "util";
@@ -3255,6 +3265,9 @@ function changeRef(ref, fn) {
3255
3265
  internals.getDoc().commit();
3256
3266
  return ref;
3257
3267
  }
3268
+ function getLoroDoc(docOrRef) {
3269
+ return loro(docOrRef).doc;
3270
+ }
3258
3271
  function createPathSelector(segments) {
3259
3272
  return {
3260
3273
  __resultType: void 0,
@@ -11179,6 +11192,14 @@ var EpochDocumentSchema = Shape.doc({
11179
11192
  version: Shape.plain.number()
11180
11193
  })
11181
11194
  });
11195
+ var SUPPORTED_IMAGE_MEDIA_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
11196
+ var ImageSourceShape = Shape.plain.discriminatedUnion("type", {
11197
+ base64: Shape.plain.struct({
11198
+ type: Shape.plain.string("base64"),
11199
+ mediaType: Shape.plain.string(),
11200
+ data: Shape.plain.string()
11201
+ })
11202
+ });
11182
11203
  var ContentBlockShape = Shape.plain.discriminatedUnion("type", {
11183
11204
  text: Shape.plain.struct({
11184
11205
  type: Shape.plain.string("text"),
@@ -11201,6 +11222,11 @@ var ContentBlockShape = Shape.plain.discriminatedUnion("type", {
11201
11222
  thinking: Shape.plain.struct({
11202
11223
  type: Shape.plain.string("thinking"),
11203
11224
  text: Shape.plain.string()
11225
+ }),
11226
+ image: Shape.plain.struct({
11227
+ type: Shape.plain.string("image"),
11228
+ id: Shape.plain.string(),
11229
+ source: ImageSourceShape
11204
11230
  })
11205
11231
  });
11206
11232
  var A2A_TASK_STATES = [
@@ -11215,6 +11241,9 @@ var A2A_TASK_STATES = [
11215
11241
  var SESSION_STATES = ["pending", "active", "completed", "failed", "interrupted"];
11216
11242
  var REASONING_EFFORTS = ["low", "medium", "high"];
11217
11243
  var PERMISSION_MODES = ["default", "accept-edits", "plan", "bypass"];
11244
+ var ANTHROPIC_AUTH_STATUSES = ["authenticated", "unauthenticated", "unknown"];
11245
+ var ANTHROPIC_AUTH_METHODS = ["api-key", "oauth", "none"];
11246
+ var ANTHROPIC_LOGIN_STATUSES = ["starting", "waiting", "done", "error"];
11218
11247
  var MessageShape = Shape.plain.struct({
11219
11248
  messageId: Shape.plain.string(),
11220
11249
  role: Shape.plain.string("user", "assistant"),
@@ -11281,6 +11310,17 @@ var DiffCommentShape = Shape.plain.struct({
11281
11310
  createdAt: Shape.plain.number(),
11282
11311
  resolvedAt: Shape.plain.number().nullable()
11283
11312
  });
11313
+ var PlanCommentShape = Shape.plain.struct({
11314
+ commentId: Shape.plain.string(),
11315
+ planId: Shape.plain.string(),
11316
+ from: Shape.plain.number(),
11317
+ to: Shape.plain.number(),
11318
+ body: Shape.plain.string(),
11319
+ authorType: Shape.plain.string(...COMMENT_AUTHOR_TYPES),
11320
+ authorId: Shape.plain.string(),
11321
+ createdAt: Shape.plain.number(),
11322
+ resolvedAt: Shape.plain.number().nullable()
11323
+ });
11284
11324
  var TaskDocumentSchema = Shape.doc({
11285
11325
  meta: Shape.struct({
11286
11326
  id: Shape.plain.string(),
@@ -11293,7 +11333,9 @@ var TaskDocumentSchema = Shape.doc({
11293
11333
  sessions: Shape.list(SessionEntryShape),
11294
11334
  diffState: DiffStateShape,
11295
11335
  plans: Shape.list(PlanVersionShape),
11296
- diffComments: Shape.record(DiffCommentShape)
11336
+ planEditorDocs: Shape.record(Shape.any()),
11337
+ diffComments: Shape.record(DiffCommentShape),
11338
+ planComments: Shape.record(PlanCommentShape)
11297
11339
  });
11298
11340
  var TOOL_RISK_LEVELS = ["low", "medium", "high"];
11299
11341
  var PERMISSION_DECISIONS = ["approved", "denied"];
@@ -11335,8 +11377,26 @@ var TaskIndexEntryShape = Shape.struct({
11335
11377
  createdAt: Shape.plain.number(),
11336
11378
  updatedAt: Shape.plain.number()
11337
11379
  });
11380
+ var WORKTREE_SETUP_STATUSES = ["running", "done", "failed"];
11381
+ var WorktreeSetupStatusShape = Shape.struct({
11382
+ status: Shape.plain.string(...WORKTREE_SETUP_STATUSES),
11383
+ machineId: Shape.plain.string(),
11384
+ startedAt: Shape.plain.number(),
11385
+ completedAt: Shape.plain.number().nullable(),
11386
+ exitCode: Shape.plain.number().nullable(),
11387
+ signal: Shape.plain.string().nullable(),
11388
+ pid: Shape.plain.number().nullable()
11389
+ });
11390
+ var WorktreeScriptShape = Shape.plain.struct({
11391
+ script: Shape.plain.string()
11392
+ });
11393
+ var UserSettingsShape = Shape.struct({
11394
+ worktreeScripts: Shape.record(WorktreeScriptShape)
11395
+ });
11338
11396
  var TaskIndexDocumentSchema = Shape.doc({
11339
- taskIndex: Shape.record(TaskIndexEntryShape)
11397
+ taskIndex: Shape.record(TaskIndexEntryShape),
11398
+ worktreeSetupStatus: Shape.record(WorktreeSetupStatusShape),
11399
+ userSettings: UserSettingsShape
11340
11400
  });
11341
11401
  var ReasoningCapabilityShape = Shape.plain.struct({
11342
11402
  efforts: Shape.plain.array(Shape.plain.string(...REASONING_EFFORTS)),
@@ -11354,14 +11414,68 @@ var GitRepoInfoShape = Shape.plain.struct({
11354
11414
  branch: Shape.plain.string(),
11355
11415
  remote: Shape.plain.string().nullable()
11356
11416
  });
11417
+ var AnthropicAuthShape = Shape.plain.struct({
11418
+ status: Shape.plain.string(...ANTHROPIC_AUTH_STATUSES),
11419
+ method: Shape.plain.string(...ANTHROPIC_AUTH_METHODS),
11420
+ email: Shape.plain.string().nullable()
11421
+ });
11357
11422
  var MachineCapabilitiesEphemeral = Shape.plain.struct({
11358
11423
  models: Shape.plain.array(ModelInfoShape),
11359
11424
  environments: Shape.plain.array(GitRepoInfoShape),
11360
11425
  permissionModes: Shape.plain.array(Shape.plain.string(...PERMISSION_MODES)),
11361
- homeDir: Shape.plain.string().nullable()
11426
+ homeDir: Shape.plain.string().nullable(),
11427
+ anthropicAuth: AnthropicAuthShape.nullable()
11428
+ });
11429
+ var EnhancePromptRequestEphemeral = Shape.plain.struct({
11430
+ machineId: Shape.plain.string(),
11431
+ prompt: Shape.plain.string(),
11432
+ requestedAt: Shape.plain.number()
11433
+ });
11434
+ var EnhancePromptResponseEphemeral = Shape.plain.struct({
11435
+ status: Shape.plain.string("streaming", "done", "error"),
11436
+ text: Shape.plain.string(),
11437
+ error: Shape.plain.string().nullable()
11438
+ });
11439
+ var WorktreeCreateRequestEphemeral = Shape.plain.struct({
11440
+ machineId: Shape.plain.string(),
11441
+ sourceRepoPath: Shape.plain.string(),
11442
+ branchName: Shape.plain.string(),
11443
+ baseRef: Shape.plain.string(),
11444
+ setupScript: Shape.plain.string().nullable(),
11445
+ requestedAt: Shape.plain.number()
11446
+ });
11447
+ var WorktreeCreateResponseEphemeral = Shape.plain.struct({
11448
+ status: Shape.plain.string("creating-worktree", "copying-files", "running-setup-script", "refreshing-environments", "done", "error"),
11449
+ detail: Shape.plain.string().nullable(),
11450
+ worktreePath: Shape.plain.string().nullable(),
11451
+ branchName: Shape.plain.string().nullable(),
11452
+ setupScriptStarted: Shape.plain.boolean().nullable(),
11453
+ warnings: Shape.plain.array(Shape.plain.string()).nullable(),
11454
+ error: Shape.plain.string().nullable()
11455
+ });
11456
+ var WorktreeSetupResultEphemeral = Shape.plain.struct({
11457
+ exitCode: Shape.plain.number().nullable(),
11458
+ signal: Shape.plain.string().nullable(),
11459
+ worktreePath: Shape.plain.string()
11460
+ });
11461
+ var AnthropicLoginRequestEphemeral = Shape.plain.struct({
11462
+ machineId: Shape.plain.string(),
11463
+ requestedAt: Shape.plain.number()
11464
+ });
11465
+ var AnthropicLoginResponseEphemeral = Shape.plain.struct({
11466
+ status: Shape.plain.string(...ANTHROPIC_LOGIN_STATUSES),
11467
+ loginUrl: Shape.plain.string().nullable(),
11468
+ error: Shape.plain.string().nullable()
11362
11469
  });
11363
11470
  var ROOM_EPHEMERAL_DECLARATIONS = {
11364
- capabilities: MachineCapabilitiesEphemeral
11471
+ capabilities: MachineCapabilitiesEphemeral,
11472
+ enhancePromptReqs: EnhancePromptRequestEphemeral,
11473
+ enhancePromptResps: EnhancePromptResponseEphemeral,
11474
+ worktreeCreateReqs: WorktreeCreateRequestEphemeral,
11475
+ worktreeCreateResps: WorktreeCreateResponseEphemeral,
11476
+ worktreeSetupResps: WorktreeSetupResultEphemeral,
11477
+ anthropicLoginReqs: AnthropicLoginRequestEphemeral,
11478
+ anthropicLoginResps: AnthropicLoginResponseEphemeral
11365
11479
  };
11366
11480
 
11367
11481
  // src/file-storage-adapter.ts
@@ -11440,6 +11554,11 @@ var FileStorageAdapter = class extends StorageAdapter {
11440
11554
  }
11441
11555
  };
11442
11556
 
11557
+ // src/lifecycle.ts
11558
+ import { unlinkSync } from "fs";
11559
+ import { readFile as readFile2, unlink as unlink2, writeFile as writeFile2 } from "fs/promises";
11560
+ import { join as join2 } from "path";
11561
+
11443
11562
  // src/logger.ts
11444
11563
  import pino from "pino";
11445
11564
  var logger = pino({
@@ -11451,11 +11570,24 @@ function createChildLogger(context) {
11451
11570
  }
11452
11571
 
11453
11572
  // src/lifecycle.ts
11573
+ function isProcessAlive(pid) {
11574
+ try {
11575
+ process.kill(pid, 0);
11576
+ return true;
11577
+ } catch {
11578
+ return false;
11579
+ }
11580
+ }
11581
+ function isEnoent(err) {
11582
+ return typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT";
11583
+ }
11454
11584
  var LifecycleManager = class {
11455
11585
  #abortControllers = /* @__PURE__ */ new Set();
11456
11586
  #shutdownCallbacks = [];
11457
11587
  #isShuttingDown = false;
11588
+ // biome-ignore lint/suspicious/noExplicitAny: process event handlers have heterogeneous signatures
11458
11589
  #signalHandlers = [];
11590
+ #pidFilePath = null;
11459
11591
  constructor() {
11460
11592
  const termHandler = () => void this.#shutdown("SIGTERM");
11461
11593
  const intHandler = () => void this.#shutdown("SIGINT");
@@ -11465,6 +11597,32 @@ var LifecycleManager = class {
11465
11597
  { signal: "SIGTERM", handler: termHandler },
11466
11598
  { signal: "SIGINT", handler: intHandler }
11467
11599
  ];
11600
+ const exceptionHandler = (error) => {
11601
+ try {
11602
+ logger.error({ error }, "Uncaught exception \u2014 initiating shutdown");
11603
+ } catch {
11604
+ }
11605
+ void this.#shutdown("uncaughtException");
11606
+ };
11607
+ const rejectionHandler = (reason) => {
11608
+ try {
11609
+ let jsonStr;
11610
+ try {
11611
+ jsonStr = JSON.stringify(reason);
11612
+ } catch {
11613
+ jsonStr = "(not serializable)";
11614
+ }
11615
+ const detail = reason instanceof Error ? { message: reason.message, stack: reason.stack } : { inspected: `${String(reason)} ${jsonStr}` };
11616
+ logger.error(detail, "Unhandled rejection (non-fatal)");
11617
+ } catch {
11618
+ }
11619
+ };
11620
+ process.on("uncaughtException", exceptionHandler);
11621
+ process.on("unhandledRejection", rejectionHandler);
11622
+ this.#signalHandlers.push(
11623
+ { signal: "uncaughtException", handler: exceptionHandler },
11624
+ { signal: "unhandledRejection", handler: rejectionHandler }
11625
+ );
11468
11626
  }
11469
11627
  destroy() {
11470
11628
  for (const { signal, handler } of this.#signalHandlers) {
@@ -11474,6 +11632,43 @@ var LifecycleManager = class {
11474
11632
  this.#isShuttingDown = false;
11475
11633
  this.#shutdownCallbacks = [];
11476
11634
  this.#abortControllers.clear();
11635
+ this.#removePidFileSync();
11636
+ }
11637
+ async acquirePidFile(shipyardHome) {
11638
+ const pidFilePath = join2(shipyardHome, "daemon.pid");
11639
+ try {
11640
+ const existing = await readFile2(pidFilePath, "utf-8");
11641
+ const pid = Number.parseInt(existing.trim(), 10);
11642
+ if (!Number.isNaN(pid) && isProcessAlive(pid)) {
11643
+ logger.error(
11644
+ { pid, pidFile: pidFilePath },
11645
+ "Another daemon is already running. Stop it first or remove the stale PID file."
11646
+ );
11647
+ process.exit(1);
11648
+ }
11649
+ logger.info({ stalePid: pid, pidFile: pidFilePath }, "Removing stale PID file");
11650
+ } catch (err) {
11651
+ if (!isEnoent(err)) throw err;
11652
+ }
11653
+ await writeFile2(pidFilePath, String(process.pid), { mode: 420 });
11654
+ this.#pidFilePath = pidFilePath;
11655
+ logger.info({ pid: process.pid, pidFile: pidFilePath }, "PID file acquired");
11656
+ }
11657
+ #removePidFileSync() {
11658
+ if (!this.#pidFilePath) return;
11659
+ try {
11660
+ unlinkSync(this.#pidFilePath);
11661
+ } catch {
11662
+ }
11663
+ this.#pidFilePath = null;
11664
+ }
11665
+ async #removePidFile() {
11666
+ if (!this.#pidFilePath) return;
11667
+ try {
11668
+ await unlink2(this.#pidFilePath);
11669
+ } catch {
11670
+ }
11671
+ this.#pidFilePath = null;
11477
11672
  }
11478
11673
  /**
11479
11674
  * Create an AbortController tracked by this manager.
@@ -11499,17 +11694,35 @@ var LifecycleManager = class {
11499
11694
  if (this.#isShuttingDown) return;
11500
11695
  this.#isShuttingDown = true;
11501
11696
  logger.info({ signal }, "Shutdown signal received");
11697
+ const HARD_KILL_MS = 15e3;
11698
+ const forceExit = setTimeout(() => {
11699
+ logger.error("Graceful shutdown timed out, forcing exit");
11700
+ process.exit(1);
11701
+ }, HARD_KILL_MS);
11702
+ forceExit.unref();
11502
11703
  for (const controller of this.#abortControllers) {
11503
11704
  controller.abort();
11504
11705
  }
11505
11706
  this.#abortControllers.clear();
11707
+ const CALLBACK_TIMEOUT_MS = 5e3;
11506
11708
  for (const callback of this.#shutdownCallbacks) {
11709
+ let timeoutId;
11507
11710
  try {
11508
- await callback();
11711
+ await Promise.race([
11712
+ callback(),
11713
+ new Promise((_, reject) => {
11714
+ timeoutId = setTimeout(
11715
+ () => reject(new Error("Shutdown callback timed out")),
11716
+ CALLBACK_TIMEOUT_MS
11717
+ );
11718
+ })
11719
+ ]).finally(() => clearTimeout(timeoutId));
11509
11720
  } catch (error) {
11510
11721
  logger.error({ error }, "Error during shutdown callback");
11511
11722
  }
11512
11723
  }
11724
+ await this.#removePidFile();
11725
+ clearTimeout(forceExit);
11513
11726
  logger.info("Shutdown complete");
11514
11727
  await new Promise((resolve3) => setTimeout(resolve3, 100));
11515
11728
  process.exit(0);
@@ -11517,9 +11730,11 @@ var LifecycleManager = class {
11517
11730
  };
11518
11731
 
11519
11732
  // src/serve.ts
11520
- import { mkdir as mkdir2 } from "fs/promises";
11733
+ import { spawn as spawn3 } from "child_process";
11734
+ import { mkdir as mkdir3 } from "fs/promises";
11521
11735
  import { homedir as homedir2, hostname as hostname2 } from "os";
11522
11736
  import { resolve } from "path";
11737
+ import { query as query2 } from "@anthropic-ai/claude-agent-sdk";
11523
11738
 
11524
11739
  // ../../node_modules/.pnpm/@loro-extended+adapter-webrtc@5.4.2_loro-crdt@1.10.5/node_modules/@loro-extended/adapter-webrtc/dist/index.js
11525
11740
  var WebRtcDataChannelAdapter = class extends Adapter {
@@ -11755,15 +11970,307 @@ var WebRtcDataChannelAdapter = class extends Adapter {
11755
11970
  }
11756
11971
  };
11757
11972
 
11973
+ // ../../packages/session/src/client.ts
11974
+ var SignalingClientError = class extends Error {
11975
+ constructor(code, message, status, details) {
11976
+ super(message);
11977
+ this.code = code;
11978
+ this.status = status;
11979
+ this.details = details;
11980
+ this.name = "SignalingClientError";
11981
+ }
11982
+ };
11983
+ var SignalingClientValidationError = class extends Error {
11984
+ constructor(message, response) {
11985
+ super(message);
11986
+ this.response = response;
11987
+ this.name = "SignalingClientValidationError";
11988
+ }
11989
+ };
11990
+ var SignalingClient = class {
11991
+ /**
11992
+ * Creates a new SignalingClient instance.
11993
+ *
11994
+ * @param baseUrl - Base URL of the signaling server (e.g., 'https://signaling.shipyard.dev')
11995
+ * @param options - Optional configuration
11996
+ */
11997
+ constructor(baseUrl, options = {}) {
11998
+ this.baseUrl = baseUrl;
11999
+ this.fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
12000
+ this.defaultHeaders = options.defaultHeaders ?? {};
12001
+ }
12002
+ fetch;
12003
+ defaultHeaders;
12004
+ /**
12005
+ * Check server health.
12006
+ *
12007
+ * GET /health
12008
+ *
12009
+ * @returns Health status with service name and environment
12010
+ * @throws {SignalingClientError} If the server returns an error
12011
+ * @throws {SignalingClientValidationError} If the response doesn't match expected schema
12012
+ */
12013
+ async health() {
12014
+ const response = await this.fetch(`${this.baseUrl}${ROUTES.HEALTH}`, {
12015
+ method: "GET",
12016
+ headers: this.defaultHeaders
12017
+ });
12018
+ const data = await response.json();
12019
+ if (!response.ok) {
12020
+ this.throwError(response.status, data);
12021
+ }
12022
+ const result = HealthResponseSchema.safeParse(data);
12023
+ if (!result.success) {
12024
+ throw new SignalingClientValidationError(
12025
+ `Invalid health response: ${result.error.message}`,
12026
+ data
12027
+ );
12028
+ }
12029
+ return result.data;
12030
+ }
12031
+ /**
12032
+ * Verify a JWT against the database.
12033
+ *
12034
+ * GET /auth/verify
12035
+ *
12036
+ * Checks both cryptographic validity and that the user still exists in the database.
12037
+ * Does NOT throw on 401 — returns `{ valid: false, reason }` instead.
12038
+ *
12039
+ * @param token - Shipyard JWT to verify
12040
+ * @returns Verification result with user info (if valid) or failure reason
12041
+ * @throws {SignalingClientError} If the server returns a non-401 error
12042
+ * @throws {SignalingClientValidationError} If the response doesn't match expected schema
12043
+ */
12044
+ async verify(token) {
12045
+ const response = await this.fetch(`${this.baseUrl}${ROUTES.AUTH_VERIFY}`, {
12046
+ method: "GET",
12047
+ headers: {
12048
+ ...this.defaultHeaders,
12049
+ Authorization: `Bearer ${token}`
12050
+ }
12051
+ });
12052
+ const data = await response.json();
12053
+ if (!response.ok && response.status !== 401) {
12054
+ this.throwError(response.status, data);
12055
+ }
12056
+ const result = AuthVerifyResponseSchema.safeParse(data);
12057
+ if (!result.success) {
12058
+ throw new SignalingClientValidationError(
12059
+ `Invalid verify response: ${result.error.message}`,
12060
+ data
12061
+ );
12062
+ }
12063
+ return result.data;
12064
+ }
12065
+ /**
12066
+ * Exchange a GitHub OAuth code for a Shipyard JWT.
12067
+ *
12068
+ * POST /auth/github/callback
12069
+ *
12070
+ * @param request - OAuth code and redirect URI
12071
+ * @returns JWT token and user info
12072
+ * @throws {SignalingClientError} If authentication fails
12073
+ * @throws {SignalingClientValidationError} If request/response doesn't match expected schema
12074
+ */
12075
+ async authGitHubCallback(request) {
12076
+ const requestResult = AuthGitHubCallbackRequestSchema.safeParse(request);
12077
+ if (!requestResult.success) {
12078
+ throw new SignalingClientValidationError(
12079
+ `Invalid request: ${requestResult.error.message}`,
12080
+ request
12081
+ );
12082
+ }
12083
+ const response = await this.fetch(`${this.baseUrl}${ROUTES.AUTH_GITHUB_CALLBACK}`, {
12084
+ method: "POST",
12085
+ headers: {
12086
+ ...this.defaultHeaders,
12087
+ "Content-Type": "application/json"
12088
+ },
12089
+ body: JSON.stringify(requestResult.data)
12090
+ });
12091
+ const data = await response.json();
12092
+ if (!response.ok) {
12093
+ this.throwError(response.status, data);
12094
+ }
12095
+ const result = AuthGitHubCallbackResponseSchema.safeParse(data);
12096
+ if (!result.success) {
12097
+ throw new SignalingClientValidationError(
12098
+ `Invalid auth response: ${result.error.message}`,
12099
+ data
12100
+ );
12101
+ }
12102
+ return result.data;
12103
+ }
12104
+ /**
12105
+ * Create a new collaboration room.
12106
+ *
12107
+ * POST /collab/create
12108
+ *
12109
+ * Requires authentication - use `withAuth()` to create an authenticated client.
12110
+ *
12111
+ * @param request - Task ID and optional expiration
12112
+ * @param token - Shipyard JWT for authentication
12113
+ * @returns Pre-signed WebSocket URL and room info
12114
+ * @throws {SignalingClientError} If authorization fails or request is invalid
12115
+ * @throws {SignalingClientValidationError} If request/response doesn't match expected schema
12116
+ */
12117
+ async createCollab(request, token) {
12118
+ const requestResult = CollabCreateRequestSchema.safeParse(request);
12119
+ if (!requestResult.success) {
12120
+ throw new SignalingClientValidationError(
12121
+ `Invalid request: ${requestResult.error.message}`,
12122
+ request
12123
+ );
12124
+ }
12125
+ const response = await this.fetch(`${this.baseUrl}${ROUTES.COLLAB_CREATE}`, {
12126
+ method: "POST",
12127
+ headers: {
12128
+ ...this.defaultHeaders,
12129
+ "Content-Type": "application/json",
12130
+ Authorization: `Bearer ${token}`
12131
+ },
12132
+ body: JSON.stringify(requestResult.data)
12133
+ });
12134
+ const data = await response.json();
12135
+ if (!response.ok) {
12136
+ this.throwError(response.status, data);
12137
+ }
12138
+ const result = CollabCreateResponseSchema.safeParse(data);
12139
+ if (!result.success) {
12140
+ throw new SignalingClientValidationError(
12141
+ `Invalid collab create response: ${result.error.message}`,
12142
+ data
12143
+ );
12144
+ }
12145
+ return result.data;
12146
+ }
12147
+ /**
12148
+ * Build a WebSocket URL for connecting to a personal room.
12149
+ *
12150
+ * Note: This method only builds the URL. Use a WebSocket library to connect.
12151
+ *
12152
+ * @param userId - The user ID to connect as
12153
+ * @param token - Shipyard JWT for authentication
12154
+ * @returns WebSocket URL for connecting to the personal room
12155
+ */
12156
+ buildPersonalRoomUrl(userId, token) {
12157
+ const wsBaseUrl = this.baseUrl.replace(/^http:/, "ws:").replace(/^https:/, "wss:");
12158
+ return `${wsBaseUrl}/personal/${encodeURIComponent(userId)}?token=${encodeURIComponent(token)}`;
12159
+ }
12160
+ /**
12161
+ * Build a WebSocket URL for connecting to a collaboration room.
12162
+ *
12163
+ * Note: This method only builds the URL. Use a WebSocket library to connect.
12164
+ * The token should be extracted from the pre-signed URL returned by createCollab().
12165
+ *
12166
+ * @param roomId - The room ID to connect to
12167
+ * @param presignedToken - The pre-signed URL token from createCollab()
12168
+ * @param userToken - Optional user JWT for authenticated users
12169
+ * @returns WebSocket URL for connecting to the collab room
12170
+ */
12171
+ buildCollabRoomUrl(roomId, presignedToken, userToken) {
12172
+ const wsBaseUrl = this.baseUrl.replace(/^http:/, "ws:").replace(/^https:/, "wss:");
12173
+ let url = `${wsBaseUrl}/collab/${encodeURIComponent(roomId)}?token=${encodeURIComponent(presignedToken)}`;
12174
+ if (userToken) {
12175
+ url += `&userToken=${encodeURIComponent(userToken)}`;
12176
+ }
12177
+ return url;
12178
+ }
12179
+ /**
12180
+ * Extract the pre-signed token from a collab URL.
12181
+ *
12182
+ * @param presignedUrl - The full pre-signed URL from createCollab()
12183
+ * @returns The token query parameter value, or null if not found
12184
+ */
12185
+ extractTokenFromPresignedUrl(presignedUrl) {
12186
+ try {
12187
+ const url = new URL(presignedUrl);
12188
+ return url.searchParams.get("token");
12189
+ } catch {
12190
+ return null;
12191
+ }
12192
+ }
12193
+ /**
12194
+ * Create an authenticated client that automatically includes the token.
12195
+ *
12196
+ * @param token - Shipyard JWT to include in all requests
12197
+ * @returns A new SignalingClient configured with the auth token
12198
+ */
12199
+ withAuth(token) {
12200
+ return new AuthenticatedSignalingClient(this.baseUrl, token, {
12201
+ fetch: this.fetch,
12202
+ defaultHeaders: this.defaultHeaders
12203
+ });
12204
+ }
12205
+ /**
12206
+ * Parse error response and throw appropriate error.
12207
+ */
12208
+ throwError(status, data) {
12209
+ const validationResult = ValidationErrorResponseSchema.safeParse(data);
12210
+ if (validationResult.success) {
12211
+ throw new SignalingClientError(
12212
+ validationResult.data.error,
12213
+ validationResult.data.message,
12214
+ status,
12215
+ validationResult.data.details
12216
+ );
12217
+ }
12218
+ const errorResult = ErrorResponseSchema.safeParse(data);
12219
+ if (errorResult.success) {
12220
+ throw new SignalingClientError(errorResult.data.error, errorResult.data.message, status);
12221
+ }
12222
+ throw new SignalingClientError("unknown_error", `Server returned status ${status}`, status);
12223
+ }
12224
+ };
12225
+ var AuthenticatedSignalingClient = class extends SignalingClient {
12226
+ constructor(baseUrl, token, options = {}) {
12227
+ super(baseUrl, options);
12228
+ this.token = token;
12229
+ }
12230
+ async createCollab(request, _token) {
12231
+ return super.createCollab(request, this.token);
12232
+ }
12233
+ buildPersonalRoomUrl(userId, _token) {
12234
+ return super.buildPersonalRoomUrl(userId, this.token);
12235
+ }
12236
+ /**
12237
+ * Build a WebSocket URL for connecting to a collaboration room with user authentication.
12238
+ *
12239
+ * @param roomId - The room ID to connect to
12240
+ * @param presignedToken - The pre-signed URL token from createCollab()
12241
+ * @returns WebSocket URL for connecting to the collab room
12242
+ */
12243
+ buildCollabRoomUrl(roomId, presignedToken) {
12244
+ return super.buildCollabRoomUrl(roomId, presignedToken, this.token);
12245
+ }
12246
+ /**
12247
+ * Verify the stored token against the database.
12248
+ *
12249
+ * Convenience wrapper around verify() using the authenticated client's token.
12250
+ */
12251
+ async verifyToken() {
12252
+ return this.verify(this.token);
12253
+ }
12254
+ /**
12255
+ * Get the authentication token.
12256
+ *
12257
+ * @returns The JWT token used for authentication
12258
+ */
12259
+ getToken() {
12260
+ return this.token;
12261
+ }
12262
+ };
12263
+
11758
12264
  // src/branch-watcher.ts
11759
12265
  import { watch } from "fs";
11760
- import { join as join3 } from "path";
12266
+ import { readFile as readFile3, stat } from "fs/promises";
12267
+ import { isAbsolute, join as join4 } from "path";
11761
12268
 
11762
12269
  // src/capabilities.ts
11763
12270
  import { execFile } from "child_process";
11764
12271
  import { readdir as readdir2 } from "fs/promises";
11765
12272
  import { homedir } from "os";
11766
- import { basename, join as join2 } from "path";
12273
+ import { basename, join as join3 } from "path";
11767
12274
  var TIMEOUT_MS = 5e3;
11768
12275
  function run(command, args, cwd) {
11769
12276
  return new Promise((resolve3, reject) => {
@@ -12033,7 +12540,7 @@ async function findGitRepos(dir, depth = 0) {
12033
12540
  if (!entry.isDirectory()) continue;
12034
12541
  if (entry.name.startsWith(".")) continue;
12035
12542
  if (EXCLUDE_DIRS.has(entry.name)) continue;
12036
- promises.push(findGitRepos(join2(dir, entry.name), depth + 1));
12543
+ promises.push(findGitRepos(join3(dir, entry.name), depth + 1));
12037
12544
  }
12038
12545
  const results = await Promise.all(promises);
12039
12546
  return results.flat();
@@ -12064,14 +12571,48 @@ async function detectEnvironments() {
12064
12571
  const repoInfos = await Promise.all(repoPaths.map(getRepoMetadata));
12065
12572
  return repoInfos.filter((info) => info !== null);
12066
12573
  }
12574
+ async function detectAnthropicAuth() {
12575
+ if (process.env.ANTHROPIC_API_KEY) {
12576
+ return { status: "authenticated", method: "api-key" };
12577
+ }
12578
+ try {
12579
+ const stdout = await run("claude", ["auth", "status", "--json"], void 0);
12580
+ const parsed = JSON.parse(stdout);
12581
+ if (parsed.loggedIn) {
12582
+ return { status: "authenticated", method: "oauth", email: parsed.email };
12583
+ }
12584
+ return { status: "unauthenticated", method: "none" };
12585
+ } catch {
12586
+ return { status: "unknown", method: "none" };
12587
+ }
12588
+ }
12067
12589
  async function detectCapabilities() {
12068
- const [models, environments] = await Promise.all([detectModels(), detectEnvironments()]);
12590
+ const [models, environments, anthropicAuth] = await Promise.all([
12591
+ detectModels(),
12592
+ detectEnvironments(),
12593
+ detectAnthropicAuth()
12594
+ ]);
12069
12595
  const permissionModes = [...PermissionModeSchema.options];
12070
- return { models, environments, permissionModes, homeDir: homedir() };
12596
+ return { models, environments, permissionModes, homeDir: homedir(), anthropicAuth };
12071
12597
  }
12072
12598
 
12073
12599
  // src/branch-watcher.ts
12074
12600
  var DEBOUNCE_MS = 500;
12601
+ async function resolveHeadPath(repoPath) {
12602
+ const gitPath = join4(repoPath, ".git");
12603
+ const gitStat = await stat(gitPath);
12604
+ if (gitStat.isDirectory()) {
12605
+ return join4(gitPath, "HEAD");
12606
+ }
12607
+ const content = await readFile3(gitPath, "utf-8");
12608
+ const match = content.trim().match(/^gitdir:\s*(.+)$/);
12609
+ if (!match?.[1]) {
12610
+ throw new Error(`Invalid .git file at ${gitPath}`);
12611
+ }
12612
+ const gitDir = match[1];
12613
+ const resolvedGitDir = isAbsolute(gitDir) ? gitDir : join4(repoPath, gitDir);
12614
+ return join4(resolvedGitDir, "HEAD");
12615
+ }
12075
12616
  function createBranchWatcher(options) {
12076
12617
  const log = logger.child({ component: "branch-watcher" });
12077
12618
  const watchers = /* @__PURE__ */ new Map();
@@ -12080,11 +12621,18 @@ function createBranchWatcher(options) {
12080
12621
  let environments = [...options.environments];
12081
12622
  for (const env of environments) {
12082
12623
  branches.set(env.path, env.branch);
12083
- startWatching(env.path);
12624
+ void startWatching(env.path);
12084
12625
  }
12085
12626
  log.info({ count: environments.length }, "Branch watcher started");
12086
- function startWatching(repoPath) {
12087
- const headPath = join3(repoPath, ".git", "HEAD");
12627
+ async function startWatching(repoPath) {
12628
+ let headPath;
12629
+ try {
12630
+ headPath = await resolveHeadPath(repoPath);
12631
+ } catch (err) {
12632
+ const msg = err instanceof Error ? err.message : String(err);
12633
+ log.debug({ repoPath, err: msg }, "Failed to resolve HEAD path, skipping watch");
12634
+ return;
12635
+ }
12088
12636
  try {
12089
12637
  const watcher = watch(headPath, () => {
12090
12638
  debouncedCheck(repoPath);
@@ -12096,7 +12644,7 @@ function createBranchWatcher(options) {
12096
12644
  watchers.set(repoPath, watcher);
12097
12645
  } catch (err) {
12098
12646
  const msg = err instanceof Error ? err.message : String(err);
12099
- log.debug({ repoPath, err: msg }, "Failed to watch .git/HEAD");
12647
+ log.debug({ repoPath, err: msg }, "Failed to watch HEAD");
12100
12648
  }
12101
12649
  }
12102
12650
  function removeWatcher(repoPath) {
@@ -12157,10 +12705,57 @@ function createBranchWatcher(options) {
12157
12705
  }
12158
12706
  watchers.clear();
12159
12707
  log.info("Branch watcher closed");
12708
+ },
12709
+ addEnvironment(repoPath, branch) {
12710
+ if (watchers.has(repoPath)) {
12711
+ log.debug({ repoPath }, "Already watching, skipping addEnvironment");
12712
+ return;
12713
+ }
12714
+ branches.set(repoPath, branch);
12715
+ environments.push({
12716
+ path: repoPath,
12717
+ name: repoPath.split("/").pop() ?? repoPath,
12718
+ branch,
12719
+ remote: void 0
12720
+ });
12721
+ void startWatching(repoPath);
12722
+ log.info({ repoPath, branch }, "Added environment to branch watcher");
12160
12723
  }
12161
12724
  };
12162
12725
  }
12163
12726
 
12727
+ // src/crash-recovery.ts
12728
+ function recoverOrphanedTask(taskDoc, log) {
12729
+ const json = taskDoc.toJSON();
12730
+ const { status } = json.meta;
12731
+ if (status !== "working" && status !== "starting" && status !== "input-required") {
12732
+ return false;
12733
+ }
12734
+ const sessions = json.sessions;
12735
+ let lastActiveIdx = -1;
12736
+ for (let i = sessions.length - 1; i >= 0; i--) {
12737
+ const s = sessions[i];
12738
+ if (s?.status === "active" || s?.status === "pending") {
12739
+ lastActiveIdx = i;
12740
+ break;
12741
+ }
12742
+ }
12743
+ change(taskDoc, (draft) => {
12744
+ if (lastActiveIdx >= 0) {
12745
+ const session = draft.sessions.get(lastActiveIdx);
12746
+ if (session) {
12747
+ session.status = "interrupted";
12748
+ session.completedAt = Date.now();
12749
+ session.error = "Daemon process exited unexpectedly";
12750
+ }
12751
+ }
12752
+ draft.meta.status = "failed";
12753
+ draft.meta.updatedAt = Date.now();
12754
+ });
12755
+ log.info({ previousStatus: status }, "Recovered orphaned task after daemon crash");
12756
+ return true;
12757
+ }
12758
+
12164
12759
  // src/peer-manager.ts
12165
12760
  var ICE_SERVERS = [{ urls: "stun:stun.l.google.com:19302" }];
12166
12761
  function machineIdToPeerId(machineId) {
@@ -12198,9 +12793,14 @@ function createPeerManager(config) {
12198
12793
  };
12199
12794
  pc.ondatachannel = (event) => {
12200
12795
  const channel = event.channel;
12201
- if (channel.label === "terminal-io") {
12202
- logger.info({ machineId }, "Terminal data channel received");
12203
- config.onTerminalChannel?.(machineId, event.channel);
12796
+ if (channel.label?.startsWith("terminal-io:")) {
12797
+ const taskId = channel.label.slice("terminal-io:".length);
12798
+ if (!taskId) {
12799
+ logger.warn({ machineId }, "Terminal channel with empty taskId, ignoring");
12800
+ return;
12801
+ }
12802
+ logger.info({ machineId, taskId }, "Terminal data channel received");
12803
+ config.onTerminalChannel?.(machineId, event.channel, taskId);
12204
12804
  } else {
12205
12805
  logger.info({ machineId }, "Data channel received from browser");
12206
12806
  config.webrtcAdapter.attachDataChannel(
@@ -12258,6 +12858,13 @@ function createPeerManager(config) {
12258
12858
  return pc;
12259
12859
  })();
12260
12860
  pendingCreates.set(fromMachineId, promise);
12861
+ const HANDSHAKE_TIMEOUT_MS = 3e4;
12862
+ setTimeout(() => {
12863
+ if (pendingCreates.get(fromMachineId) === promise) {
12864
+ pendingCreates.delete(fromMachineId);
12865
+ logger.warn({ fromMachineId }, "WebRTC handshake timed out");
12866
+ }
12867
+ }, HANDSHAKE_TIMEOUT_MS);
12261
12868
  await promise;
12262
12869
  },
12263
12870
  async handleAnswer(fromMachineId, answer) {
@@ -12296,73 +12903,381 @@ function createPeerManager(config) {
12296
12903
  pc.close();
12297
12904
  }
12298
12905
  peers.clear();
12906
+ pendingCreates.clear();
12299
12907
  }
12300
12908
  };
12301
12909
  }
12302
12910
 
12303
- // src/pty-manager.ts
12304
- import * as pty from "node-pty";
12305
- var KILL_TIMEOUT_MS = 5e3;
12306
- var DEFAULT_COLS = 80;
12307
- var DEFAULT_ROWS = 24;
12308
- function createPtyManager() {
12309
- const log = createChildLogger({ mode: "pty" });
12310
- let process2 = null;
12311
- let isAlive = false;
12312
- let killTimer = null;
12313
- const dataCallbacks = [];
12314
- const exitCallbacks = [];
12315
- function getDefaultShell() {
12316
- return globalThis.process.env.SHELL ?? "/bin/zsh";
12317
- }
12318
- function spawn2(options) {
12319
- if (isAlive) {
12320
- throw new Error("PTY already spawned. Call kill() or dispose() first.");
12321
- }
12322
- const shell = options.shell ?? getDefaultShell();
12323
- const cols = options.cols ?? DEFAULT_COLS;
12324
- const rows = options.rows ?? DEFAULT_ROWS;
12325
- const env = {};
12326
- for (const [key, val] of Object.entries({ ...globalThis.process.env, ...options.env })) {
12327
- if (val !== void 0) env[key] = val;
12328
- }
12329
- log.info({ shell, cwd: options.cwd, cols, rows }, "Spawning PTY");
12330
- try {
12331
- process2 = pty.spawn(shell, ["--login"], {
12332
- name: "xterm-256color",
12333
- cols,
12334
- rows,
12335
- cwd: options.cwd,
12336
- env
12337
- });
12338
- } catch (err) {
12339
- const msg = err instanceof Error ? err.message : String(err);
12340
- log.error({ err: msg, shell }, "Failed to spawn PTY");
12341
- throw new Error(`Failed to spawn PTY: ${msg}`);
12911
+ // src/plan-editor/format-plan-feedback.ts
12912
+ function formatPlanFeedbackForClaudeCode(original, edited, comments, generalFeedback) {
12913
+ const sections = [];
12914
+ if (generalFeedback) {
12915
+ sections.push("## General Feedback\n");
12916
+ sections.push(generalFeedback);
12917
+ sections.push("");
12918
+ }
12919
+ if (comments.length > 0) {
12920
+ appendCommentSection(sections, comments, edited);
12921
+ }
12922
+ if (original !== edited) {
12923
+ sections.push("## Edits Made\n");
12924
+ sections.push("The user edited the following sections of the plan:\n");
12925
+ sections.push("```diff");
12926
+ sections.push(computeLineDiff(original, edited));
12927
+ sections.push("```");
12928
+ sections.push("");
12929
+ }
12930
+ return sections.join("\n").trim();
12931
+ }
12932
+ function appendCommentSection(sections, comments, edited) {
12933
+ sections.push("## Inline Comments\n");
12934
+ sections.push("The user left the following comments on specific parts of the plan:\n");
12935
+ const editedLines = edited.split("\n");
12936
+ const sorted = [...comments].sort((a, b) => a.from - b.from);
12937
+ for (const comment of sorted) {
12938
+ const nearbyLine = findNearestLine(editedLines, comment.from);
12939
+ if (nearbyLine) {
12940
+ sections.push(`> Near "${truncate(nearbyLine, 60)}": ${comment.body}`);
12941
+ } else {
12942
+ sections.push(`> ${comment.body}`);
12342
12943
  }
12343
- isAlive = true;
12344
- process2.onData((data) => {
12345
- for (const cb of dataCallbacks) {
12346
- cb(data);
12347
- }
12348
- });
12349
- process2.onExit(({ exitCode, signal }) => {
12350
- log.info({ exitCode, signal, pid: process2?.pid }, "PTY exited");
12351
- isAlive = false;
12352
- clearKillTimer();
12353
- for (const cb of exitCallbacks) {
12354
- cb(exitCode, signal);
12355
- }
12356
- });
12357
- log.info({ pid: process2.pid }, "PTY spawned");
12358
12944
  }
12359
- function write(data) {
12360
- if (!process2 || !isAlive) {
12361
- throw new Error("PTY is not running");
12945
+ sections.push("");
12946
+ }
12947
+ function findNearestLine(lines, charOffset) {
12948
+ let pos = 0;
12949
+ for (const line of lines) {
12950
+ if (pos + line.length >= charOffset) {
12951
+ const trimmed = line.trim();
12952
+ if (trimmed.length > 0) return trimmed;
12362
12953
  }
12363
- process2.write(data);
12954
+ pos += line.length + 1;
12364
12955
  }
12365
- function resize(cols, rows) {
12956
+ for (let i = lines.length - 1; i >= 0; i--) {
12957
+ const trimmed = lines[i]?.trim();
12958
+ if (trimmed && trimmed.length > 0) return trimmed;
12959
+ }
12960
+ return null;
12961
+ }
12962
+ function truncate(text, maxLen) {
12963
+ if (text.length <= maxLen) return text;
12964
+ return `${text.slice(0, maxLen - 3)}...`;
12965
+ }
12966
+ function computeLineDiff(original, edited) {
12967
+ const oldLines = original.split("\n");
12968
+ const newLines = edited.split("\n");
12969
+ const result = [];
12970
+ let oi = 0;
12971
+ let ni = 0;
12972
+ while (oi < oldLines.length && ni < newLines.length) {
12973
+ if (oldLines[oi] === newLines[ni]) {
12974
+ result.push(` ${oldLines[oi]}`);
12975
+ oi++;
12976
+ ni++;
12977
+ } else {
12978
+ const consumed = consumeMismatch(oldLines, newLines, oi, ni, result);
12979
+ oi = consumed.oi;
12980
+ ni = consumed.ni;
12981
+ }
12982
+ }
12983
+ appendRemainder(result, oldLines, oi, "-");
12984
+ appendRemainder(result, newLines, ni, "+");
12985
+ return result.join("\n");
12986
+ }
12987
+ function consumeMismatch(oldLines, newLines, oi, ni, result) {
12988
+ const newInOld = findNext(oldLines, newLines[ni], oi);
12989
+ const oldInNew = findNext(newLines, oldLines[oi], ni);
12990
+ if (newInOld !== null && (oldInNew === null || newInOld - oi <= oldInNew - ni)) {
12991
+ while (oi < newInOld) {
12992
+ result.push(`-${oldLines[oi]}`);
12993
+ oi++;
12994
+ }
12995
+ } else if (oldInNew !== null) {
12996
+ while (ni < oldInNew) {
12997
+ result.push(`+${newLines[ni]}`);
12998
+ ni++;
12999
+ }
13000
+ } else {
13001
+ result.push(`-${oldLines[oi]}`);
13002
+ result.push(`+${newLines[ni]}`);
13003
+ oi++;
13004
+ ni++;
13005
+ }
13006
+ return { oi, ni };
13007
+ }
13008
+ function appendRemainder(result, lines, start, prefix) {
13009
+ for (let i = start; i < lines.length; i++) {
13010
+ result.push(`${prefix}${lines[i]}`);
13011
+ }
13012
+ }
13013
+ function findNext(lines, target, from) {
13014
+ if (target === void 0) return null;
13015
+ const lookAhead = 5;
13016
+ for (let i = from; i < Math.min(from + lookAhead, lines.length); i++) {
13017
+ if (lines[i] === target) return i;
13018
+ }
13019
+ return null;
13020
+ }
13021
+
13022
+ // src/plan-editor/init-plan-editor-doc.ts
13023
+ import { EditorState } from "@tiptap/pm/state";
13024
+ import { LoroMap as LoroMap3 } from "loro-crdt";
13025
+ import { updateLoroToPmState } from "loro-prosemirror";
13026
+
13027
+ // src/plan-editor/schema.ts
13028
+ import { getSchema, Mark } from "@tiptap/core";
13029
+ import StarterKit from "@tiptap/starter-kit";
13030
+ import MarkdownIt from "markdown-it";
13031
+ import { MarkdownParser, MarkdownSerializer } from "prosemirror-markdown";
13032
+ var CommentMark = Mark.create({
13033
+ name: "comment",
13034
+ addAttributes() {
13035
+ return { commentId: { default: null } };
13036
+ }
13037
+ });
13038
+ var planEditorSchema = getSchema([StarterKit.configure({ undoRedo: false }), CommentMark]);
13039
+ var configuredDocs = /* @__PURE__ */ new WeakSet();
13040
+ function configurePlanEditorTextStyles(loroDoc) {
13041
+ if (configuredDocs.has(loroDoc)) return;
13042
+ configuredDocs.add(loroDoc);
13043
+ const config = {};
13044
+ for (const [name, markType] of Object.entries(planEditorSchema.marks)) {
13045
+ const expand = markType.spec.inclusive !== false ? "after" : "none";
13046
+ config[name] = { expand };
13047
+ }
13048
+ loroDoc.configTextStyle(config);
13049
+ }
13050
+ var planEditorParser = new MarkdownParser(
13051
+ planEditorSchema,
13052
+ MarkdownIt("commonmark", { html: false }).enable("strikethrough").disable("image"),
13053
+ {
13054
+ blockquote: { block: "blockquote" },
13055
+ paragraph: { block: "paragraph" },
13056
+ list_item: { block: "listItem" },
13057
+ bullet_list: { block: "bulletList" },
13058
+ ordered_list: {
13059
+ block: "orderedList",
13060
+ getAttrs: (tok) => ({ start: Number(tok.attrGet("start") ?? 1) })
13061
+ },
13062
+ heading: {
13063
+ block: "heading",
13064
+ getAttrs: (tok) => ({ level: Number(tok.tag.slice(1)) })
13065
+ },
13066
+ code_block: { block: "codeBlock", noCloseToken: true },
13067
+ fence: {
13068
+ block: "codeBlock",
13069
+ getAttrs: (tok) => ({ language: tok.info || null }),
13070
+ noCloseToken: true
13071
+ },
13072
+ hr: { node: "horizontalRule" },
13073
+ hardbreak: { node: "hardBreak" },
13074
+ link: {
13075
+ mark: "link",
13076
+ getAttrs: (tok) => ({ href: tok.attrGet("href"), target: tok.attrGet("target") })
13077
+ },
13078
+ em: { mark: "italic" },
13079
+ strong: { mark: "bold" },
13080
+ code_inline: { mark: "code", noCloseToken: true },
13081
+ s: { mark: "strike" }
13082
+ }
13083
+ );
13084
+ function backticksFor(node, side) {
13085
+ const ticks = /`+/g;
13086
+ let len = 0;
13087
+ if (node.isText && node.text) {
13088
+ for (const match of node.text.matchAll(ticks)) {
13089
+ len = Math.max(len, match[0].length);
13090
+ }
13091
+ }
13092
+ let result = len > 0 && side > 0 ? " `" : "`";
13093
+ for (let i = 0; i < len; i++) result += "`";
13094
+ if (len > 0 && side < 0) result += " ";
13095
+ return result;
13096
+ }
13097
+ var planEditorSerializer = new MarkdownSerializer(
13098
+ {
13099
+ blockquote(state, node) {
13100
+ state.wrapBlock("> ", null, node, () => state.renderContent(node));
13101
+ },
13102
+ codeBlock(state, node) {
13103
+ const info = String(node.attrs.language ?? "");
13104
+ state.write(`\`\`\`${info}
13105
+ `);
13106
+ state.text(node.textContent, false);
13107
+ state.ensureNewLine();
13108
+ state.write("```");
13109
+ state.closeBlock(node);
13110
+ },
13111
+ heading(state, node) {
13112
+ state.write(`${state.repeat("#", Number(node.attrs.level))} `);
13113
+ state.renderInline(node);
13114
+ state.closeBlock(node);
13115
+ },
13116
+ horizontalRule(state, node) {
13117
+ state.write("---");
13118
+ state.closeBlock(node);
13119
+ },
13120
+ bulletList(state, node) {
13121
+ state.renderList(node, " ", () => "- ");
13122
+ },
13123
+ orderedList(state, node) {
13124
+ const start = Number(node.attrs.start) || 1;
13125
+ const maxW = String(start + node.childCount - 1).length;
13126
+ const space = state.repeat(" ", maxW + 2);
13127
+ state.renderList(node, space, (i) => {
13128
+ const nStr = String(start + i);
13129
+ return `${state.repeat(" ", maxW - nStr.length)}${nStr}. `;
13130
+ });
13131
+ },
13132
+ listItem(state, node) {
13133
+ state.renderContent(node);
13134
+ },
13135
+ paragraph(state, node) {
13136
+ state.renderInline(node);
13137
+ state.closeBlock(node);
13138
+ },
13139
+ hardBreak(state, node, parent, index) {
13140
+ for (let i = index + 1; i < parent.childCount; i++) {
13141
+ if (parent.child(i).type !== node.type) {
13142
+ state.write("\\\n");
13143
+ return;
13144
+ }
13145
+ }
13146
+ },
13147
+ text(state, node) {
13148
+ state.text(node.text ?? "");
13149
+ }
13150
+ },
13151
+ {
13152
+ italic: { open: "*", close: "*", mixable: true, expelEnclosingWhitespace: true },
13153
+ bold: { open: "**", close: "**", mixable: true, expelEnclosingWhitespace: true },
13154
+ strike: { open: "~~", close: "~~", mixable: true, expelEnclosingWhitespace: true },
13155
+ code: {
13156
+ open(_state, _mark, parent, index) {
13157
+ return backticksFor(parent.child(index), -1);
13158
+ },
13159
+ close(_state, _mark, parent, index) {
13160
+ return backticksFor(parent.child(index - 1), 1);
13161
+ },
13162
+ escape: false
13163
+ },
13164
+ link: {
13165
+ open: "[",
13166
+ close(_state, mark) {
13167
+ const href = String(mark.attrs.href ?? "");
13168
+ const title = mark.attrs.title ? ` "${String(mark.attrs.title)}"` : "";
13169
+ return `](${href}${title})`;
13170
+ }
13171
+ },
13172
+ comment: { open: "", close: "" }
13173
+ },
13174
+ { hardBreakNodeName: "hardBreak", strict: false }
13175
+ );
13176
+
13177
+ // src/plan-editor/init-plan-editor-doc.ts
13178
+ function initPlanEditorDoc(loroDoc, planId, markdown) {
13179
+ try {
13180
+ const pmDoc = planEditorParser.parse(markdown);
13181
+ const planEditorDocs = loroDoc.getMap("planEditorDocs");
13182
+ const planContainer = planEditorDocs.setContainer(planId, new LoroMap3());
13183
+ configurePlanEditorTextStyles(loroDoc);
13184
+ const editorState = EditorState.create({ doc: pmDoc, schema: planEditorSchema });
13185
+ const mapping = /* @__PURE__ */ new Map();
13186
+ updateLoroToPmState(loroDoc, mapping, editorState, planContainer.id);
13187
+ loroDoc.commit();
13188
+ return true;
13189
+ } catch (error) {
13190
+ logger.warn({ planId, error }, "initPlanEditorDoc failed");
13191
+ return false;
13192
+ }
13193
+ }
13194
+
13195
+ // src/plan-editor/serialize-plan-editor-doc.ts
13196
+ import { LoroMap as LoroMap4 } from "loro-crdt";
13197
+ import { createNodeFromLoroObj } from "loro-prosemirror";
13198
+ function tryCreateNode(raw, mapping) {
13199
+ try {
13200
+ return createNodeFromLoroObj(planEditorSchema, raw, mapping);
13201
+ } catch {
13202
+ return null;
13203
+ }
13204
+ }
13205
+ function serializePlanEditorDoc(loroDoc, planId) {
13206
+ const planEditorDocs = loroDoc.getMap("planEditorDocs");
13207
+ const raw = planEditorDocs.get(planId);
13208
+ if (!(raw instanceof LoroMap4)) return "";
13209
+ const mapping = /* @__PURE__ */ new Map();
13210
+ const pmNode = tryCreateNode(raw, mapping);
13211
+ if (!pmNode) return "";
13212
+ if (pmNode.childCount === 0 || pmNode.childCount === 1 && pmNode.firstChild?.textContent === "") {
13213
+ return "";
13214
+ }
13215
+ return planEditorSerializer.serialize(pmNode, { tightLists: true });
13216
+ }
13217
+
13218
+ // src/pty-manager.ts
13219
+ import * as pty from "node-pty";
13220
+ var KILL_TIMEOUT_MS = 5e3;
13221
+ var DEFAULT_COLS = 80;
13222
+ var DEFAULT_ROWS = 24;
13223
+ function createPtyManager() {
13224
+ const log = createChildLogger({ mode: "pty" });
13225
+ let process2 = null;
13226
+ let isAlive = false;
13227
+ let killTimer = null;
13228
+ const dataCallbacks = [];
13229
+ const exitCallbacks = [];
13230
+ function getDefaultShell() {
13231
+ return globalThis.process.env.SHELL ?? "/bin/zsh";
13232
+ }
13233
+ function spawn4(options) {
13234
+ if (isAlive) {
13235
+ throw new Error("PTY already spawned. Call kill() or dispose() first.");
13236
+ }
13237
+ const shell = options.shell ?? getDefaultShell();
13238
+ const cols = options.cols ?? DEFAULT_COLS;
13239
+ const rows = options.rows ?? DEFAULT_ROWS;
13240
+ const env = {};
13241
+ for (const [key, val] of Object.entries({ ...globalThis.process.env, ...options.env })) {
13242
+ if (val !== void 0) env[key] = val;
13243
+ }
13244
+ log.info({ shell, cwd: options.cwd, cols, rows }, "Spawning PTY");
13245
+ try {
13246
+ process2 = pty.spawn(shell, ["--login"], {
13247
+ name: "xterm-256color",
13248
+ cols,
13249
+ rows,
13250
+ cwd: options.cwd,
13251
+ env
13252
+ });
13253
+ } catch (err) {
13254
+ const msg = err instanceof Error ? err.message : String(err);
13255
+ log.error({ err: msg, shell }, "Failed to spawn PTY");
13256
+ throw new Error(`Failed to spawn PTY: ${msg}`);
13257
+ }
13258
+ isAlive = true;
13259
+ process2.onData((data) => {
13260
+ for (const cb of dataCallbacks) {
13261
+ cb(data);
13262
+ }
13263
+ });
13264
+ process2.onExit(({ exitCode, signal }) => {
13265
+ log.info({ exitCode, signal, pid: process2?.pid }, "PTY exited");
13266
+ isAlive = false;
13267
+ clearKillTimer();
13268
+ for (const cb of exitCallbacks) {
13269
+ cb(exitCode, signal);
13270
+ }
13271
+ });
13272
+ log.info({ pid: process2.pid }, "PTY spawned");
13273
+ }
13274
+ function write(data) {
13275
+ if (!process2 || !isAlive) {
13276
+ throw new Error("PTY is not running");
13277
+ }
13278
+ process2.write(data);
13279
+ }
13280
+ function resize(cols, rows) {
12366
13281
  if (!process2 || !isAlive) {
12367
13282
  throw new Error("PTY is not running");
12368
13283
  }
@@ -12407,7 +13322,7 @@ function createPtyManager() {
12407
13322
  get alive() {
12408
13323
  return isAlive;
12409
13324
  },
12410
- spawn: spawn2,
13325
+ spawn: spawn4,
12411
13326
  write,
12412
13327
  resize,
12413
13328
  onData,
@@ -12480,6 +13395,49 @@ var StreamingInputController = class {
12480
13395
  };
12481
13396
 
12482
13397
  // src/session-manager.ts
13398
+ var IDLE_TIMEOUT_MS = 5 * 60 * 1e3;
13399
+ var IDLE_CHECK_INTERVAL_MS = 3e4;
13400
+ var SAFE_MEDIA_TYPES = new Set(SUPPORTED_IMAGE_MEDIA_TYPES);
13401
+ function isSafeMediaType(value) {
13402
+ return SAFE_MEDIA_TYPES.has(value);
13403
+ }
13404
+ function toSdkContent(blocks) {
13405
+ const imageBlocks = [];
13406
+ const textBlocks = [];
13407
+ for (const block of blocks) {
13408
+ if (block.type === "image") imageBlocks.push(block);
13409
+ else if (block.type === "text") textBlocks.push(block);
13410
+ }
13411
+ const result = [];
13412
+ for (let i = 0; i < imageBlocks.length; i++) {
13413
+ const img = imageBlocks[i];
13414
+ if (!img) continue;
13415
+ if (img.source.type !== "base64") {
13416
+ logger.warn({ sourceType: img.source.type }, "Skipping image with unsupported source type");
13417
+ continue;
13418
+ }
13419
+ if (!isSafeMediaType(img.source.mediaType)) {
13420
+ logger.warn(
13421
+ { mediaType: img.source.mediaType },
13422
+ "Skipping image with unsupported media type"
13423
+ );
13424
+ continue;
13425
+ }
13426
+ result.push({ type: "text", text: `Attachment ${i + 1}:` });
13427
+ result.push({
13428
+ type: "image",
13429
+ source: {
13430
+ type: "base64",
13431
+ media_type: img.source.mediaType,
13432
+ data: img.source.data
13433
+ }
13434
+ });
13435
+ }
13436
+ for (const block of textBlocks) {
13437
+ result.push({ type: "text", text: block.text });
13438
+ }
13439
+ return result;
13440
+ }
12483
13441
  function safeStringify(value) {
12484
13442
  try {
12485
13443
  return JSON.stringify(value);
@@ -12525,6 +13483,17 @@ function parseSdkBlock(block, parentToolUseId) {
12525
13483
  return parseToolResultBlock(block, parentToolUseId);
12526
13484
  case "thinking":
12527
13485
  return typeof block.thinking === "string" ? { type: "thinking", text: block.thinking } : null;
13486
+ case "image": {
13487
+ const source = block.source;
13488
+ if (source && source.type === "base64" && typeof source.media_type === "string" && typeof source.data === "string") {
13489
+ return {
13490
+ type: "image",
13491
+ id: typeof block.id === "string" ? block.id : nanoid(),
13492
+ source: { type: "base64", mediaType: source.media_type, data: source.data }
13493
+ };
13494
+ }
13495
+ return null;
13496
+ }
12528
13497
  default:
12529
13498
  return null;
12530
13499
  }
@@ -12563,6 +13532,24 @@ var SessionManager = class {
12563
13532
  }
12564
13533
  return null;
12565
13534
  }
13535
+ /**
13536
+ * Extract the latest user message as content blocks (text + image).
13537
+ * Used when sending messages to Claude so images are included.
13538
+ * Filters out tool_use/tool_result/thinking blocks that aren't
13539
+ * relevant when constructing a new prompt.
13540
+ */
13541
+ getLatestUserContentBlocks() {
13542
+ const conversation = this.#taskDoc.toJSON().conversation;
13543
+ for (let i = conversation.length - 1; i >= 0; i--) {
13544
+ const msg = conversation[i];
13545
+ if (msg?.role === "user") {
13546
+ return msg.content.filter(
13547
+ (block) => block.type === "text" || block.type === "image"
13548
+ );
13549
+ }
13550
+ }
13551
+ return null;
13552
+ }
12566
13553
  /**
12567
13554
  * Determine whether to resume an existing session or start fresh.
12568
13555
  *
@@ -12614,7 +13601,7 @@ var SessionManager = class {
12614
13601
  });
12615
13602
  this.#notifyStatusChange("starting");
12616
13603
  const controller = new StreamingInputController();
12617
- controller.push(opts.prompt);
13604
+ controller.push(typeof opts.prompt === "string" ? opts.prompt : toSdkContent(opts.prompt));
12618
13605
  const response = query({
12619
13606
  prompt: controller.iterable(),
12620
13607
  options: {
@@ -12649,7 +13636,7 @@ var SessionManager = class {
12649
13636
  if (!this.#inputController || this.#inputController.isDone) {
12650
13637
  throw new Error("No active streaming session to send follow-up to");
12651
13638
  }
12652
- this.#inputController.push(prompt);
13639
+ this.#inputController.push(typeof prompt === "string" ? prompt : toSdkContent(prompt));
12653
13640
  change(this.#taskDoc, (draft) => {
12654
13641
  draft.meta.status = "working";
12655
13642
  draft.meta.updatedAt = Date.now();
@@ -12709,7 +13696,7 @@ var SessionManager = class {
12709
13696
  });
12710
13697
  this.#notifyStatusChange("starting");
12711
13698
  const controller = new StreamingInputController();
12712
- controller.push(prompt);
13699
+ controller.push(typeof prompt === "string" ? prompt : toSdkContent(prompt));
12713
13700
  const response = query({
12714
13701
  prompt: controller.iterable(),
12715
13702
  options: {
@@ -12738,8 +13725,22 @@ var SessionManager = class {
12738
13725
  }
12739
13726
  async #processMessages(response, sessionId) {
12740
13727
  let agentSessionId = "";
13728
+ let lastMessageAt = Date.now();
13729
+ let idleTimedOut = false;
13730
+ const idleTimer = setInterval(() => {
13731
+ if (Date.now() - lastMessageAt >= IDLE_TIMEOUT_MS) {
13732
+ clearInterval(idleTimer);
13733
+ idleTimedOut = true;
13734
+ logger.warn(
13735
+ { sessionId, idleMs: Date.now() - lastMessageAt },
13736
+ "Session idle timeout, closing"
13737
+ );
13738
+ response.close();
13739
+ }
13740
+ }, IDLE_CHECK_INTERVAL_MS);
12741
13741
  try {
12742
13742
  for await (const message of response) {
13743
+ lastMessageAt = Date.now();
12743
13744
  const result = this.#handleMessage(message, sessionId, agentSessionId);
12744
13745
  if (result.agentSessionId) {
12745
13746
  agentSessionId = result.agentSessionId;
@@ -12749,24 +13750,26 @@ var SessionManager = class {
12749
13750
  }
12750
13751
  }
12751
13752
  } catch (error) {
12752
- const errorMsg = error instanceof Error ? error.message : String(error);
12753
- this.#markFailed(sessionId, errorMsg);
13753
+ const errorMsg2 = idleTimedOut ? "Session idle timeout exceeded" : error instanceof Error ? error.message : String(error);
13754
+ this.#markFailed(sessionId, errorMsg2);
12754
13755
  return {
12755
13756
  sessionId,
12756
13757
  agentSessionId,
12757
13758
  status: "failed",
12758
- error: errorMsg
13759
+ error: errorMsg2
12759
13760
  };
12760
13761
  } finally {
13762
+ clearInterval(idleTimer);
12761
13763
  this.#inputController = null;
12762
13764
  this.#activeQuery = null;
12763
13765
  }
12764
- this.#markFailed(sessionId, "Session ended without result message");
13766
+ const errorMsg = idleTimedOut ? "Session idle timeout exceeded" : "Session ended without result message";
13767
+ this.#markFailed(sessionId, errorMsg);
12765
13768
  return {
12766
13769
  sessionId,
12767
13770
  agentSessionId,
12768
13771
  status: "failed",
12769
- error: "Session ended without result message"
13772
+ error: errorMsg
12770
13773
  };
12771
13774
  }
12772
13775
  /**
@@ -12779,7 +13782,27 @@ var SessionManager = class {
12779
13782
  return sessions.findIndex((s) => s.sessionId === sessionId);
12780
13783
  }
12781
13784
  #handleMessage(message, sessionId, agentSessionId) {
12782
- if (message.type === "system" && "subtype" in message && message.subtype === "init") {
13785
+ switch (message.type) {
13786
+ case "system":
13787
+ return this.#handleSystemMessage(message, sessionId);
13788
+ case "assistant":
13789
+ return this.#handleAssistantMsg(message, sessionId);
13790
+ case "user":
13791
+ if (!("isReplay" in message && message.isReplay)) {
13792
+ this.#appendUserToolResults(message);
13793
+ }
13794
+ return {};
13795
+ case "result":
13796
+ return { sessionResult: this.#handleResult(message, sessionId, agentSessionId) };
13797
+ case "tool_progress":
13798
+ this.#handleToolProgress(message);
13799
+ return {};
13800
+ default:
13801
+ return {};
13802
+ }
13803
+ }
13804
+ #handleSystemMessage(message, sessionId) {
13805
+ if ("subtype" in message && message.subtype === "init") {
12783
13806
  const initSessionId = message.session_id;
12784
13807
  if ("model" in message && typeof message.model === "string") {
12785
13808
  this.#currentModel = message.model;
@@ -12797,24 +13820,27 @@ var SessionManager = class {
12797
13820
  this.#notifyStatusChange("working");
12798
13821
  return { agentSessionId: initSessionId };
12799
13822
  }
12800
- if (message.type === "assistant") {
12801
- if ("error" in message && message.error) {
12802
- logger.warn({ error: message.error, sessionId }, "Assistant message carried an error");
12803
- }
12804
- this.#appendAssistantMessage(message);
12805
- return {};
12806
- }
12807
- if (message.type === "user" && !("isReplay" in message && message.isReplay)) {
12808
- this.#appendUserToolResults(message);
12809
- return {};
13823
+ if ("subtype" in message && message.subtype === "task_notification") {
13824
+ const taskId = "task_id" in message ? message.task_id : "unknown";
13825
+ const status = "status" in message ? message.status : "unknown";
13826
+ const summary = "summary" in message ? message.summary : "";
13827
+ logger.info({ taskId, status, summary }, "Received task_notification from subagent");
12810
13828
  }
12811
- if (message.type === "result") {
12812
- return {
12813
- sessionResult: this.#handleResult(message, sessionId, agentSessionId)
12814
- };
13829
+ return {};
13830
+ }
13831
+ #handleAssistantMsg(message, sessionId) {
13832
+ if ("error" in message && message.error) {
13833
+ logger.warn({ error: message.error, sessionId }, "Assistant message carried an error");
12815
13834
  }
13835
+ this.#appendAssistantMessage(message);
12816
13836
  return {};
12817
13837
  }
13838
+ #handleToolProgress(message) {
13839
+ const toolName = "tool_name" in message ? message.tool_name : "unknown";
13840
+ const toolUseId = "tool_use_id" in message ? message.tool_use_id : "unknown";
13841
+ const elapsedSeconds = "elapsed_time_seconds" in message ? message.elapsed_time_seconds : 0;
13842
+ logger.debug({ toolName, toolUseId, elapsedSeconds }, "tool_progress heartbeat");
13843
+ }
12818
13844
  /**
12819
13845
  * Extract tool_result blocks from SDK user messages (which carry tool outputs)
12820
13846
  * and append them to the last assistant conversation entry so the UI can
@@ -12912,9 +13938,10 @@ var SessionManager = class {
12912
13938
  );
12913
13939
  continue;
12914
13940
  }
13941
+ const planId = nanoid();
12915
13942
  change(this.#taskDoc, (draft) => {
12916
13943
  draft.plans.push({
12917
- planId: nanoid(),
13944
+ planId,
12918
13945
  toolUseId: block.toolUseId,
12919
13946
  markdown: planMarkdown,
12920
13947
  reviewStatus: "pending",
@@ -12922,7 +13949,18 @@ var SessionManager = class {
12922
13949
  createdAt: Date.now()
12923
13950
  });
12924
13951
  });
12925
- logger.info({ toolUseId: block.toolUseId }, "Extracted plan from ExitPlanMode tool call");
13952
+ const loroDoc = getLoroDoc(this.#taskDoc);
13953
+ const initOk = initPlanEditorDoc(loroDoc, planId, planMarkdown);
13954
+ if (!initOk) {
13955
+ logger.warn(
13956
+ { planId, toolUseId: block.toolUseId },
13957
+ "Failed to initialize plan editor doc from markdown"
13958
+ );
13959
+ }
13960
+ logger.info(
13961
+ { toolUseId: block.toolUseId, planId },
13962
+ "Extracted plan from ExitPlanMode tool call"
13963
+ );
12926
13964
  }
12927
13965
  }
12928
13966
  #handleResult(message, sessionId, agentSessionId) {
@@ -13016,8 +14054,9 @@ async function createSignalingHandle(env, log) {
13016
14054
  if (!env.SHIPYARD_SIGNALING_URL) {
13017
14055
  return null;
13018
14056
  }
13019
- const machineId = env.SHIPYARD_MACHINE_ID ?? hostname();
13020
- const machineName = env.SHIPYARD_MACHINE_NAME ?? hostname();
14057
+ const devSuffix = env.SHIPYARD_DEV ? "-dev" : "";
14058
+ const machineId = env.SHIPYARD_MACHINE_ID ?? `${hostname()}${devSuffix}`;
14059
+ const machineName = env.SHIPYARD_MACHINE_NAME ?? `${hostname()}${devSuffix}`;
13021
14060
  const wsUrl = new URL(env.SHIPYARD_SIGNALING_URL);
13022
14061
  if (!wsUrl.pathname.includes("/personal/")) {
13023
14062
  if (!env.SHIPYARD_USER_ID) {
@@ -13053,40 +14092,280 @@ async function createSignalingHandle(env, log) {
13053
14092
  return { signaling, connection, capabilities };
13054
14093
  }
13055
14094
 
13056
- // src/serve.ts
13057
- function assertNever(x) {
13058
- throw new Error(`Unhandled message type: ${JSON.stringify(x)}`);
14095
+ // src/worktree-cleanup.ts
14096
+ function isPidAlive(pid) {
14097
+ try {
14098
+ process.kill(pid, 0);
14099
+ return true;
14100
+ } catch {
14101
+ return false;
14102
+ }
13059
14103
  }
13060
- var CONTROL_PREFIX = "\0\0";
13061
- var TERMINAL_BUFFER_MAX_BYTES = 1048576;
13062
- var TERMINAL_OPEN_TIMEOUT_MS = 1e4;
13063
- var TERMINAL_CWD_TIMEOUT_MS = 5e3;
13064
- function resolveTerminalCwd(activeTasks, watchedTasks, repo) {
13065
- for (const taskId of activeTasks.keys()) {
13066
- const epoch = DEFAULT_EPOCH;
13067
- const taskDocId = buildDocumentId("task", taskId, epoch);
13068
- try {
13069
- const handle = repo.get(taskDocId, TaskDocumentSchema);
13070
- const json = handle.doc.toJSON();
13071
- const lastUserMsg = [...json.conversation].reverse().find((m) => m.role === "user");
13072
- if (lastUserMsg?.cwd) return lastUserMsg.cwd;
13073
- } catch {
14104
+ function classifyEntries(entries, localMachineId, cutoff) {
14105
+ const staleKeys = [];
14106
+ const orphanedEntries = [];
14107
+ for (const [path, entry] of Object.entries(entries)) {
14108
+ if (entry.status !== "running") {
14109
+ if (entry.completedAt && entry.completedAt < cutoff) {
14110
+ staleKeys.push(path);
14111
+ }
14112
+ continue;
14113
+ }
14114
+ if (entry.machineId === localMachineId) {
14115
+ const alive = entry.pid != null && isPidAlive(entry.pid);
14116
+ if (!alive) {
14117
+ orphanedEntries.push({ path, entry });
14118
+ }
13074
14119
  }
13075
14120
  }
13076
- for (const taskId of watchedTasks.keys()) {
13077
- if (activeTasks.has(taskId)) continue;
13078
- const epoch = DEFAULT_EPOCH;
13079
- const taskDocId = buildDocumentId("task", taskId, epoch);
14121
+ return { staleKeys, orphanedEntries };
14122
+ }
14123
+ function cleanupStaleSetupEntries(roomDoc, localMachineId, log) {
14124
+ const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1e3;
14125
+ const cutoff = Date.now() - SEVEN_DAYS_MS;
14126
+ try {
14127
+ const json = roomDoc.toJSON();
14128
+ const entries = json.worktreeSetupStatus ?? {};
14129
+ const { staleKeys, orphanedEntries } = classifyEntries(entries, localMachineId, cutoff);
14130
+ if (staleKeys.length === 0 && orphanedEntries.length === 0) return;
14131
+ change(roomDoc, (draft) => {
14132
+ for (const key of staleKeys) {
14133
+ draft.worktreeSetupStatus.delete(key);
14134
+ }
14135
+ for (const { path, entry } of orphanedEntries) {
14136
+ draft.worktreeSetupStatus.set(path, {
14137
+ ...entry,
14138
+ status: "failed",
14139
+ completedAt: Date.now()
14140
+ });
14141
+ }
14142
+ });
14143
+ if (staleKeys.length > 0) {
14144
+ log.info({ count: staleKeys.length }, "Cleaned up stale worktree setup status entries");
14145
+ }
14146
+ if (orphanedEntries.length > 0) {
14147
+ log.info(
14148
+ { count: orphanedEntries.length, paths: orphanedEntries.map((e) => e.path) },
14149
+ "Marked orphaned running setup entries as failed"
14150
+ );
14151
+ }
14152
+ } catch (err) {
14153
+ log.warn({ err }, "Failed to clean up stale worktree setup status entries");
14154
+ }
14155
+ }
14156
+
14157
+ // src/worktree-command.ts
14158
+ import { execFile as execFile2, spawn as spawn2 } from "child_process";
14159
+ import { closeSync, openSync } from "fs";
14160
+ import { access, chmod, constants, copyFile, mkdir as mkdir2, writeFile as writeFile3 } from "fs/promises";
14161
+ import { dirname as dirname2, isAbsolute as isAbsolute2, join as join5 } from "path";
14162
+ var GIT_TIMEOUT_MS = 3e4;
14163
+ function isErrnoException(err) {
14164
+ return err instanceof Error && "code" in err;
14165
+ }
14166
+ var MAX_BUFFER = 10 * 1024 * 1024;
14167
+ function runGit(args, cwd) {
14168
+ return new Promise((resolve3, reject) => {
14169
+ execFile2(
14170
+ "git",
14171
+ args,
14172
+ { timeout: GIT_TIMEOUT_MS, cwd, maxBuffer: MAX_BUFFER },
14173
+ (error, stdout) => {
14174
+ if (error) reject(error);
14175
+ else resolve3(stdout.trim());
14176
+ }
14177
+ );
14178
+ });
14179
+ }
14180
+ var COPY_EXCLUDE_PATTERNS = [
14181
+ "node_modules",
14182
+ ".turbo",
14183
+ ".data/",
14184
+ "/dist/",
14185
+ "/build/",
14186
+ ".next",
14187
+ ".DS_Store",
14188
+ "*.log",
14189
+ ".eslintcache"
14190
+ ];
14191
+ function matchesPattern(filePath, pattern) {
14192
+ const segments = filePath.split("/");
14193
+ if (pattern.startsWith("*")) return filePath.endsWith(pattern.slice(1));
14194
+ let bare = pattern;
14195
+ if (bare.startsWith("/")) bare = bare.slice(1);
14196
+ if (bare.endsWith("/")) bare = bare.slice(0, -1);
14197
+ return segments.some((seg) => seg === bare);
14198
+ }
14199
+ function shouldExclude(filePath) {
14200
+ return COPY_EXCLUDE_PATTERNS.some((pattern) => matchesPattern(filePath, pattern));
14201
+ }
14202
+ async function refExists(ref, cwd) {
14203
+ try {
14204
+ await runGit(["rev-parse", "--verify", ref], cwd);
14205
+ return true;
14206
+ } catch {
14207
+ return false;
14208
+ }
14209
+ }
14210
+ async function assertWorktreeNotExists(worktreePath) {
14211
+ try {
14212
+ await access(worktreePath, constants.F_OK);
14213
+ } catch (err) {
14214
+ if (isErrnoException(err) && err.code === "ENOENT") return;
14215
+ return;
14216
+ }
14217
+ throw new Error(`Worktree already exists at ${worktreePath}`);
14218
+ }
14219
+ async function addWorktree(worktreePath, branchName, baseRef, cwd) {
14220
+ const remoteExists = await refExists(`origin/${branchName}`, cwd);
14221
+ if (remoteExists) {
13080
14222
  try {
13081
- const handle = repo.get(taskDocId, TaskDocumentSchema);
13082
- const json = handle.doc.toJSON();
13083
- const lastUserMsg = [...json.conversation].reverse().find((m) => m.role === "user");
13084
- if (lastUserMsg?.cwd) return lastUserMsg.cwd;
14223
+ await runGit(["worktree", "add", worktreePath, branchName], cwd);
14224
+ return;
13085
14225
  } catch {
14226
+ await runGit(
14227
+ ["worktree", "add", worktreePath, "-b", branchName, `origin/${branchName}`],
14228
+ cwd
14229
+ );
14230
+ return;
13086
14231
  }
13087
14232
  }
13088
- return process.cwd();
14233
+ const localExists = await refExists(branchName, cwd);
14234
+ if (localExists) {
14235
+ await runGit(["worktree", "add", worktreePath, branchName], cwd);
14236
+ return;
14237
+ }
14238
+ const normalizedRef = baseRef.startsWith("origin/") ? baseRef.slice("origin/".length) : baseRef;
14239
+ await runGit(["worktree", "add", worktreePath, "-b", branchName, `origin/${normalizedRef}`], cwd);
13089
14240
  }
14241
+ async function copyIgnoredFiles(sourceRepoPath, worktreePath) {
14242
+ const warnings = [];
14243
+ const ignoredOutput = await runGit(
14244
+ ["ls-files", "--others", "--ignored", "--exclude-standard"],
14245
+ sourceRepoPath
14246
+ );
14247
+ if (!ignoredOutput) return warnings;
14248
+ const files = ignoredOutput.split("\n").filter((f) => f && !shouldExclude(f));
14249
+ for (const file of files) {
14250
+ try {
14251
+ const destPath = join5(worktreePath, file);
14252
+ await mkdir2(dirname2(destPath), { recursive: true });
14253
+ await copyFile(join5(sourceRepoPath, file), destPath);
14254
+ } catch (err) {
14255
+ const msg = err instanceof Error ? err.message : String(err);
14256
+ warnings.push(`Failed to copy ${file}: ${msg}`);
14257
+ }
14258
+ }
14259
+ return warnings;
14260
+ }
14261
+ async function launchSetupScript(worktreePath, setupScript) {
14262
+ if (!setupScript) return null;
14263
+ const shipyardDir = join5(worktreePath, ".shipyard");
14264
+ const scriptPath = join5(shipyardDir, "worktree-setup.sh");
14265
+ const logPath = join5(shipyardDir, "worktree-setup.log");
14266
+ let fullScript;
14267
+ if (setupScript.startsWith("#!")) {
14268
+ const firstNewline = setupScript.indexOf("\n");
14269
+ const shebang = setupScript.slice(0, firstNewline + 1);
14270
+ const rest = setupScript.slice(firstNewline + 1);
14271
+ fullScript = `${shebang}set -e
14272
+ ${rest}`;
14273
+ } else {
14274
+ fullScript = `#!/bin/sh
14275
+ set -e
14276
+ ${setupScript}`;
14277
+ }
14278
+ await mkdir2(shipyardDir, { recursive: true });
14279
+ await writeFile3(scriptPath, fullScript, "utf-8");
14280
+ await chmod(scriptPath, 493);
14281
+ const logFd = openSync(logPath, "w");
14282
+ try {
14283
+ const child = spawn2(scriptPath, [], {
14284
+ cwd: worktreePath,
14285
+ stdio: ["ignore", logFd, logFd],
14286
+ detached: true
14287
+ });
14288
+ return child;
14289
+ } finally {
14290
+ closeSync(logFd);
14291
+ }
14292
+ }
14293
+ async function createWorktree(opts) {
14294
+ const { sourceRepoPath, branchName, baseRef, setupScript, onProgress } = opts;
14295
+ validateWorktreeInputs(sourceRepoPath, branchName, baseRef);
14296
+ const worktreeParent = `${sourceRepoPath}-wt`;
14297
+ const worktreePath = join5(worktreeParent, branchName);
14298
+ onProgress("creating-worktree", `Creating worktree at ${worktreePath}`);
14299
+ await mkdir2(worktreeParent, { recursive: true });
14300
+ await assertWorktreeNotExists(worktreePath);
14301
+ try {
14302
+ await runGit(["fetch", "origin", baseRef], sourceRepoPath);
14303
+ } catch {
14304
+ }
14305
+ await addWorktree(worktreePath, branchName, baseRef, sourceRepoPath);
14306
+ onProgress("copying-files", "Copying ignored files from source repo");
14307
+ let warnings = [];
14308
+ try {
14309
+ warnings = await copyIgnoredFiles(sourceRepoPath, worktreePath);
14310
+ onProgress("copying-files", `Copied ignored files (${warnings.length} warnings)`);
14311
+ } catch (err) {
14312
+ const msg = err instanceof Error ? err.message : String(err);
14313
+ warnings.push(`Failed to list ignored files: ${msg}`);
14314
+ }
14315
+ const setupChild = await launchSetupScript(worktreePath, setupScript);
14316
+ const setupScriptStarted = setupChild !== null;
14317
+ if (setupScriptStarted) {
14318
+ onProgress("running-setup-script", "Launched worktree setup script");
14319
+ }
14320
+ return { worktreePath, branchName, setupScriptStarted, setupChild, warnings };
14321
+ }
14322
+ var BASE_REF_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9/_.-]*$/;
14323
+ function validateWorktreeInputs(sourceRepoPath, branchName, baseRef) {
14324
+ if (!isAbsolute2(sourceRepoPath)) {
14325
+ throw new Error(`sourceRepoPath must be an absolute path, got: ${sourceRepoPath}`);
14326
+ }
14327
+ if (sourceRepoPath.includes("..")) {
14328
+ throw new Error('sourceRepoPath must not contain ".." path traversal segments');
14329
+ }
14330
+ if (branchName.includes("..")) {
14331
+ throw new Error('branchName must not contain ".." path traversal segments');
14332
+ }
14333
+ if (branchName.startsWith("-") || branchName.startsWith(".")) {
14334
+ throw new Error('branchName must not start with "-" or "."');
14335
+ }
14336
+ if (!BRANCH_NAME_PATTERN.test(branchName)) {
14337
+ throw new Error(
14338
+ `branchName contains invalid characters. Must match ${BRANCH_NAME_PATTERN.source}`
14339
+ );
14340
+ }
14341
+ if (baseRef.startsWith("-")) {
14342
+ throw new Error('baseRef must not start with "-"');
14343
+ }
14344
+ if (baseRef.includes("..")) {
14345
+ throw new Error('baseRef must not contain ".." path traversal segments');
14346
+ }
14347
+ if (!BASE_REF_PATTERN.test(baseRef)) {
14348
+ throw new Error(`baseRef contains invalid characters. Must match ${BASE_REF_PATTERN.source}`);
14349
+ }
14350
+ }
14351
+
14352
+ // src/serve.ts
14353
+ function assertNever(x) {
14354
+ throw new Error(`Unhandled message type: ${JSON.stringify(x)}`);
14355
+ }
14356
+ var processedRequestIds = /* @__PURE__ */ new Set();
14357
+ var pendingCleanupTimers = /* @__PURE__ */ new Set();
14358
+ function scheduleEphemeralCleanup(fn, delayMs) {
14359
+ const timer = setTimeout(() => {
14360
+ pendingCleanupTimers.delete(timer);
14361
+ fn();
14362
+ }, delayMs);
14363
+ pendingCleanupTimers.add(timer);
14364
+ }
14365
+ var CONTROL_PREFIX = "\0\0";
14366
+ var TERMINAL_BUFFER_MAX_BYTES = 1048576;
14367
+ var TERMINAL_OPEN_TIMEOUT_MS = 1e4;
14368
+ var TERMINAL_CWD_TIMEOUT_MS = 5e3;
13090
14369
  var TaskEphemeralDeclarations = {
13091
14370
  permReqs: PermissionRequestEphemeral,
13092
14371
  permResps: PermissionResponseEphemeral
@@ -13097,7 +14376,23 @@ async function serve(env) {
13097
14376
  process.exit(1);
13098
14377
  }
13099
14378
  const log = createChildLogger({ mode: "serve" });
14379
+ if (env.SHIPYARD_USER_TOKEN && env.SHIPYARD_SIGNALING_URL) {
14380
+ const client = new SignalingClient(env.SHIPYARD_SIGNALING_URL);
14381
+ try {
14382
+ const result = await client.verify(env.SHIPYARD_USER_TOKEN);
14383
+ if (!result.valid) {
14384
+ logger.error(
14385
+ `Auth token is no longer valid (${result.reason}). Run \`shipyard login\` to re-authenticate.`
14386
+ );
14387
+ process.exit(1);
14388
+ }
14389
+ log.info("Token verified against session server");
14390
+ } catch {
14391
+ log.warn("Could not verify token with session server, proceeding anyway");
14392
+ }
14393
+ }
13100
14394
  const lifecycle = new LifecycleManager();
14395
+ await lifecycle.acquirePidFile(getShipyardHome());
13101
14396
  const handle = await createSignalingHandle(env, log);
13102
14397
  if (!handle) {
13103
14398
  logger.error("SHIPYARD_SIGNALING_URL is required for serve mode");
@@ -13106,9 +14401,11 @@ async function serve(env) {
13106
14401
  const { signaling, connection, capabilities } = handle;
13107
14402
  const activeTasks = /* @__PURE__ */ new Map();
13108
14403
  const watchedTasks = /* @__PURE__ */ new Map();
13109
- const machineId = env.SHIPYARD_MACHINE_ID ?? hostname2();
14404
+ const taskHandles = /* @__PURE__ */ new Map();
14405
+ const devSuffix = env.SHIPYARD_DEV ? "-dev" : "";
14406
+ const machineId = env.SHIPYARD_MACHINE_ID ?? `${hostname2()}${devSuffix}`;
13110
14407
  const dataDir = resolve(env.SHIPYARD_DATA_DIR.replace("~", homedir2()));
13111
- await mkdir2(dataDir, { recursive: true, mode: 448 });
14408
+ await mkdir3(dataDir, { recursive: true, mode: 448 });
13112
14409
  const storage = new FileStorageAdapter(dataDir);
13113
14410
  const webrtcAdapter = new WebRtcDataChannelAdapter();
13114
14411
  const repo = new Repo({
@@ -13132,17 +14429,18 @@ async function serve(env) {
13132
14429
  candidate
13133
14430
  });
13134
14431
  },
13135
- onTerminalChannel(fromMachineId, rawChannel) {
14432
+ onTerminalChannel(fromMachineId, rawChannel, taskId) {
13136
14433
  const channel = rawChannel;
13137
- const termLog = createChildLogger({ mode: `terminal:${fromMachineId}` });
13138
- const existingPty = terminalPtys.get(fromMachineId);
14434
+ const terminalKey = `${fromMachineId}:${taskId}`;
14435
+ const termLog = createChildLogger({ mode: `terminal:${fromMachineId}:${taskId}` });
14436
+ const existingPty = terminalPtys.get(terminalKey);
13139
14437
  if (existingPty) {
13140
14438
  termLog.info("Disposing existing PTY for reconnecting machine");
13141
14439
  existingPty.dispose();
13142
- terminalPtys.delete(fromMachineId);
14440
+ terminalPtys.delete(terminalKey);
13143
14441
  }
13144
14442
  const ptyManager = createPtyManager();
13145
- terminalPtys.set(fromMachineId, ptyManager);
14443
+ terminalPtys.set(terminalKey, ptyManager);
13146
14444
  let ptySpawned = false;
13147
14445
  let channelOpen = channel.readyState === "open";
13148
14446
  const pendingBuffer = [];
@@ -13153,7 +14451,7 @@ async function serve(env) {
13153
14451
  clearTimeout(openTimeout);
13154
14452
  clearTimeout(cwdTimeout);
13155
14453
  ptyManager.dispose();
13156
- terminalPtys.delete(fromMachineId);
14454
+ terminalPtys.delete(terminalKey);
13157
14455
  if (channel.readyState === "open") {
13158
14456
  channel.close();
13159
14457
  }
@@ -13205,7 +14503,7 @@ async function serve(env) {
13205
14503
  } catch (err) {
13206
14504
  termLog.error({ err }, "Failed to spawn terminal PTY");
13207
14505
  channel.close();
13208
- terminalPtys.delete(fromMachineId);
14506
+ terminalPtys.delete(terminalKey);
13209
14507
  return;
13210
14508
  }
13211
14509
  ptyManager.onData((data) => {
@@ -13216,7 +14514,7 @@ async function serve(env) {
13216
14514
  if (channel.readyState === "open") {
13217
14515
  channel.close();
13218
14516
  }
13219
- terminalPtys.delete(fromMachineId);
14517
+ terminalPtys.delete(terminalKey);
13220
14518
  });
13221
14519
  for (const input of preSpawnInputBuffer) {
13222
14520
  try {
@@ -13232,8 +14530,8 @@ async function serve(env) {
13232
14530
  }
13233
14531
  const cwdTimeout = setTimeout(() => {
13234
14532
  if (!ptySpawned) {
13235
- termLog.info("No cwd control message received, falling back to heuristic");
13236
- const fallbackCwd = resolveTerminalCwd(activeTasks, watchedTasks, repo);
14533
+ termLog.info("No cwd control message received, falling back to $HOME");
14534
+ const fallbackCwd = process.env.HOME ?? process.cwd();
13237
14535
  spawnPty(fallbackCwd);
13238
14536
  }
13239
14537
  }, TERMINAL_CWD_TIMEOUT_MS);
@@ -13273,7 +14571,9 @@ async function serve(env) {
13273
14571
  clearTimeout(openTimeout);
13274
14572
  clearTimeout(cwdTimeout);
13275
14573
  ptyManager.dispose();
13276
- terminalPtys.delete(fromMachineId);
14574
+ if (terminalPtys.get(terminalKey) === ptyManager) {
14575
+ terminalPtys.delete(terminalKey);
14576
+ }
13277
14577
  };
13278
14578
  }
13279
14579
  });
@@ -13294,7 +14594,12 @@ async function serve(env) {
13294
14594
  remote: e.remote ?? null
13295
14595
  })),
13296
14596
  permissionModes: caps.permissionModes,
13297
- homeDir: caps.homeDir ?? null
14597
+ homeDir: caps.homeDir ?? null,
14598
+ anthropicAuth: caps.anthropicAuth ? {
14599
+ status: caps.anthropicAuth.status,
14600
+ method: caps.anthropicAuth.method,
14601
+ email: caps.anthropicAuth.email ?? null
14602
+ } : null
13298
14603
  };
13299
14604
  roomHandle.capabilities.set(machineId, value);
13300
14605
  log.info({ machineId }, "Published capabilities to room ephemeral");
@@ -13307,6 +14612,165 @@ async function serve(env) {
13307
14612
  publishCapabilities(capabilities);
13308
14613
  }
13309
14614
  });
14615
+ const typedRoomHandle = roomHandle;
14616
+ const typedRoomDoc = roomHandle.doc;
14617
+ cleanupStaleSetupEntries(typedRoomDoc, machineId, log);
14618
+ typedRoomHandle.enhancePromptReqs.subscribe(({ key: requestId, value, source }) => {
14619
+ if (source !== "remote") return;
14620
+ if (!value) return;
14621
+ if (value.machineId !== machineId) return;
14622
+ if (processedRequestIds.has(requestId)) return;
14623
+ processedRequestIds.add(requestId);
14624
+ const enhLog = createChildLogger({ mode: `enhance-prompt-ephemeral:${requestId}` });
14625
+ enhLog.info(
14626
+ { promptLen: value.prompt.length },
14627
+ "Received enhance-prompt request via ephemeral"
14628
+ );
14629
+ if (capabilities.anthropicAuth?.status !== "authenticated") {
14630
+ enhLog.error("Not authenticated with Anthropic");
14631
+ typedRoomHandle.enhancePromptResps.set(requestId, {
14632
+ status: "error",
14633
+ text: "",
14634
+ error: "Not authenticated with Anthropic. Run 'claude auth login' or set ANTHROPIC_API_KEY."
14635
+ });
14636
+ scheduleEphemeralCleanup(() => {
14637
+ typedRoomHandle.enhancePromptReqs.delete(requestId);
14638
+ typedRoomHandle.enhancePromptResps.delete(requestId);
14639
+ }, EPHEMERAL_CLEANUP_DELAY_MS);
14640
+ processedRequestIds.delete(requestId);
14641
+ return;
14642
+ }
14643
+ const abortController = lifecycle.createAbortController();
14644
+ const timeout = setTimeout(() => abortController.abort(), ENHANCE_PROMPT_TIMEOUT_MS);
14645
+ runEnhancePromptEphemeral(value.prompt, requestId, abortController, typedRoomHandle, enhLog).catch((err) => {
14646
+ enhLog.error({ err }, "Enhance prompt ephemeral failed");
14647
+ }).finally(() => {
14648
+ clearTimeout(timeout);
14649
+ abortController.abort();
14650
+ processedRequestIds.delete(requestId);
14651
+ });
14652
+ });
14653
+ typedRoomHandle.worktreeCreateReqs.subscribe(({ key: requestId, value, source }) => {
14654
+ if (source !== "remote") return;
14655
+ if (!value) return;
14656
+ if (value.machineId !== machineId) return;
14657
+ if (processedRequestIds.has(requestId)) return;
14658
+ processedRequestIds.add(requestId);
14659
+ const wtLog = createChildLogger({ mode: `worktree-create-ephemeral:${requestId}` });
14660
+ wtLog.info(
14661
+ {
14662
+ sourceRepoPath: value.sourceRepoPath,
14663
+ branchName: value.branchName,
14664
+ baseRef: value.baseRef
14665
+ },
14666
+ "Received worktree-create request via ephemeral"
14667
+ );
14668
+ const roomJson = typedRoomDoc.toJSON();
14669
+ const crdtScriptEntry = roomJson?.userSettings?.worktreeScripts?.[value.sourceRepoPath];
14670
+ const resolvedSetupScript = value.setupScript ?? crdtScriptEntry?.script ?? null;
14671
+ runWorktreeCreateEphemeral(
14672
+ requestId,
14673
+ value.sourceRepoPath,
14674
+ value.branchName,
14675
+ value.baseRef,
14676
+ resolvedSetupScript,
14677
+ typedRoomHandle,
14678
+ typedRoomDoc,
14679
+ machineId,
14680
+ capabilities,
14681
+ publishCapabilities,
14682
+ branchWatcher,
14683
+ wtLog
14684
+ ).catch((err) => {
14685
+ wtLog.error({ err }, "Worktree create ephemeral handler failed");
14686
+ }).finally(() => {
14687
+ processedRequestIds.delete(requestId);
14688
+ });
14689
+ });
14690
+ typedRoomHandle.anthropicLoginReqs.subscribe(({ key: requestId, value, source }) => {
14691
+ if (source !== "remote") return;
14692
+ if (!value) return;
14693
+ if (value.machineId !== machineId) return;
14694
+ if (processedRequestIds.has(requestId)) return;
14695
+ processedRequestIds.add(requestId);
14696
+ const loginLog = createChildLogger({ mode: `anthropic-login:${requestId}` });
14697
+ loginLog.info("Received Anthropic login request via ephemeral");
14698
+ typedRoomHandle.anthropicLoginResps.set(requestId, {
14699
+ status: "starting",
14700
+ loginUrl: null,
14701
+ error: null
14702
+ });
14703
+ const child = spawn3("claude", ["auth", "login"], {
14704
+ stdio: ["ignore", "pipe", "pipe"]
14705
+ });
14706
+ let stdout = "";
14707
+ child.stdout?.on("data", (chunk) => {
14708
+ const text = chunk.toString();
14709
+ stdout += text;
14710
+ const urlMatch = text.match(/https:\/\/\S+/);
14711
+ if (urlMatch) {
14712
+ typedRoomHandle.anthropicLoginResps.set(requestId, {
14713
+ status: "waiting",
14714
+ loginUrl: urlMatch[0],
14715
+ error: null
14716
+ });
14717
+ }
14718
+ });
14719
+ child.stderr?.on("data", (chunk) => {
14720
+ const text = chunk.toString();
14721
+ stdout += text;
14722
+ const urlMatch = text.match(/https:\/\/\S+/);
14723
+ if (urlMatch) {
14724
+ typedRoomHandle.anthropicLoginResps.set(requestId, {
14725
+ status: "waiting",
14726
+ loginUrl: urlMatch[0],
14727
+ error: null
14728
+ });
14729
+ }
14730
+ });
14731
+ child.on("exit", (exitCode) => {
14732
+ if (exitCode === 0) {
14733
+ loginLog.info("Anthropic login completed successfully");
14734
+ detectAnthropicAuth().then((authStatus) => {
14735
+ capabilities.anthropicAuth = authStatus;
14736
+ publishCapabilities(capabilities);
14737
+ loginLog.info({ authStatus }, "Re-detected auth after login");
14738
+ }).catch((err) => {
14739
+ loginLog.warn({ err }, "Failed to re-detect auth after login");
14740
+ });
14741
+ typedRoomHandle.anthropicLoginResps.set(requestId, {
14742
+ status: "done",
14743
+ loginUrl: null,
14744
+ error: null
14745
+ });
14746
+ } else {
14747
+ loginLog.error({ exitCode, stdout }, "Anthropic login failed");
14748
+ typedRoomHandle.anthropicLoginResps.set(requestId, {
14749
+ status: "error",
14750
+ loginUrl: null,
14751
+ error: `Login failed (exit code ${exitCode})`
14752
+ });
14753
+ }
14754
+ scheduleEphemeralCleanup(() => {
14755
+ typedRoomHandle.anthropicLoginReqs.delete(requestId);
14756
+ typedRoomHandle.anthropicLoginResps.delete(requestId);
14757
+ }, EPHEMERAL_CLEANUP_DELAY_MS);
14758
+ processedRequestIds.delete(requestId);
14759
+ });
14760
+ child.on("error", (err) => {
14761
+ loginLog.error({ err: err.message }, "Failed to spawn claude auth login");
14762
+ typedRoomHandle.anthropicLoginResps.set(requestId, {
14763
+ status: "error",
14764
+ loginUrl: null,
14765
+ error: `Failed to start login: ${err.message}`
14766
+ });
14767
+ scheduleEphemeralCleanup(() => {
14768
+ typedRoomHandle.anthropicLoginReqs.delete(requestId);
14769
+ typedRoomHandle.anthropicLoginResps.delete(requestId);
14770
+ }, EPHEMERAL_CLEANUP_DELAY_MS);
14771
+ processedRequestIds.delete(requestId);
14772
+ });
14773
+ });
13310
14774
  connection.onStateChange((state) => {
13311
14775
  log.info({ state }, "Connection state changed");
13312
14776
  });
@@ -13316,13 +14780,18 @@ async function serve(env) {
13316
14780
  signaling,
13317
14781
  connection,
13318
14782
  repo,
13319
- roomDoc: roomHandle.doc,
14783
+ roomDoc: typedRoomDoc,
14784
+ roomHandle: typedRoomHandle,
13320
14785
  lifecycle,
13321
14786
  activeTasks,
13322
14787
  watchedTasks,
14788
+ taskHandles,
13323
14789
  peerManager,
13324
14790
  env,
13325
- machineId
14791
+ machineId,
14792
+ capabilities,
14793
+ publishCapabilities,
14794
+ branchWatcher
13326
14795
  });
13327
14796
  });
13328
14797
  connection.connect();
@@ -13347,6 +14816,9 @@ async function serve(env) {
13347
14816
  unsub();
13348
14817
  }
13349
14818
  watchedTasks.clear();
14819
+ taskHandles.clear();
14820
+ for (const timer of pendingCleanupTimers) clearTimeout(timer);
14821
+ pendingCleanupTimers.clear();
13350
14822
  for (const [id, ptyMgr] of terminalPtys) {
13351
14823
  ptyMgr.dispose();
13352
14824
  terminalPtys.delete(id);
@@ -13504,6 +14976,21 @@ function handleMessage(msg, ctx) {
13504
14976
  });
13505
14977
  break;
13506
14978
  }
14979
+ case "enhance-prompt-request":
14980
+ handleEnhancePrompt(msg, ctx);
14981
+ break;
14982
+ case "enhance-prompt-chunk":
14983
+ case "enhance-prompt-done":
14984
+ ctx.log.debug({ type: msg.type }, "Enhance prompt echo");
14985
+ break;
14986
+ case "worktree-create-request":
14987
+ handleWorktreeCreate(msg, ctx);
14988
+ break;
14989
+ case "worktree-create-progress":
14990
+ case "worktree-create-done":
14991
+ case "worktree-create-error":
14992
+ ctx.log.debug({ type: msg.type }, "Worktree create echo");
14993
+ break;
13507
14994
  case "authenticated":
13508
14995
  case "agent-joined":
13509
14996
  case "agent-left":
@@ -13518,25 +15005,29 @@ function handleMessage(msg, ctx) {
13518
15005
  function handleNotifyTask(msg, ctx) {
13519
15006
  const { taskId, requestId } = msg;
13520
15007
  const taskLog = createChildLogger({ mode: "serve", taskId });
13521
- if (!ctx.env.ANTHROPIC_API_KEY) {
13522
- taskLog.error("ANTHROPIC_API_KEY is required to run agents");
15008
+ if (ctx.capabilities.anthropicAuth?.status !== "authenticated") {
15009
+ taskLog.error("Not authenticated with Anthropic");
13523
15010
  ctx.connection.send({
13524
15011
  type: "task-ack",
13525
15012
  requestId,
13526
15013
  taskId,
13527
15014
  accepted: false,
13528
- error: "ANTHROPIC_API_KEY not configured on daemon"
15015
+ error: "Not authenticated with Anthropic. Run 'claude auth login' or set ANTHROPIC_API_KEY."
13529
15016
  });
13530
15017
  return;
13531
15018
  }
13532
15019
  if (ctx.watchedTasks.has(taskId)) {
13533
- taskLog.debug("Task already watched, sending ack");
15020
+ taskLog.debug("Task already watched, re-checking for new work");
13534
15021
  ctx.connection.send({
13535
15022
  type: "task-ack",
13536
15023
  requestId,
13537
15024
  taskId,
13538
15025
  accepted: true
13539
15026
  });
15027
+ const taskHandle = ctx.taskHandles.get(taskId);
15028
+ if (taskHandle) {
15029
+ onTaskDocChanged(taskId, taskHandle, taskLog, ctx);
15030
+ }
13540
15031
  return;
13541
15032
  }
13542
15033
  taskLog.info("Received notify-task, starting watch");
@@ -13551,6 +15042,424 @@ function handleNotifyTask(msg, ctx) {
13551
15042
  taskLog.error({ err: errMsg }, "Failed to start watching task document");
13552
15043
  });
13553
15044
  }
15045
+ var ENHANCE_PROMPT_TIMEOUT_MS = 3e4;
15046
+ 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.
15047
+
15048
+ RULES:
15049
+ - Write in imperative/request form ("Build...", "Create...", "Add...") \u2014 NEVER first person ("I'll...", "Let me...")
15050
+ - Return ONLY the rewritten prompt. No preamble, explanation, or markdown formatting.
15051
+ - Keep the user's core intent intact. Do not add features they didn't imply.
15052
+ - Expand vague ideas into concrete specifics: name technologies, list features, set constraints.
15053
+ - If the user names a technology, keep it. If they don't, pick sensible defaults.
15054
+ - Target 2-5x the input length. Be concise and direct \u2014 no filler, hype, or superlatives.
15055
+ - Do NOT add project setup boilerplate (e.g., "initialize a git repo", "set up CI/CD") unless asked.
15056
+ - Structure as a single paragraph or short bullet list, whichever is clearer.
15057
+
15058
+ WHAT TO ADD:
15059
+ - Specific features the request implies but doesn't list
15060
+ - Technology choices when unspecified (framework, API, library)
15061
+ - Concrete parameters (sizes, ranges, counts) instead of vague adjectives
15062
+ - UI/UX details when building something visual
15063
+ - Scope boundaries to prevent over-building
15064
+
15065
+ EXAMPLES:
15066
+
15067
+ Input: "make a cool hello world beat app"
15068
+ Output: "Build a browser-based beat maker with a 4x4 pad grid that plays drum sounds on click. Include kick, snare, hi-hat, and clap samples using the Web Audio API. Add a step sequencer with play/pause, a tempo slider (60-200 BPM), and visual feedback on active pads. Keep it to a single page with a dark theme."
15069
+
15070
+ Input: "fix the login bug"
15071
+ Output: "Investigate and fix the login bug. Check the authentication flow for common issues: expired tokens, incorrect credential validation, session handling errors, or CORS misconfigurations. Add error logging if missing and verify the fix works for both valid and invalid credential cases."
15072
+
15073
+ Input: "add dark mode"
15074
+ Output: "Add a dark mode toggle to the app. Use CSS custom properties for theme colors, persist the user's preference in localStorage, and respect the system prefers-color-scheme setting as the default. Ensure all existing components have adequate contrast in both themes."
15075
+
15076
+ Input: "make a landing page for my saas"
15077
+ Output: "Build a responsive landing page with: hero section with headline, subheadline, and CTA button; features grid (3-4 cards with icons); pricing section with 2-3 tier cards; FAQ accordion; and a footer with links. Use a clean, modern design with consistent spacing. Make it mobile-first with a sticky header."`;
15078
+ function handleEnhancePrompt(msg, ctx) {
15079
+ const { requestId, prompt } = msg;
15080
+ if (processedRequestIds.has(requestId)) return;
15081
+ processedRequestIds.add(requestId);
15082
+ const enhanceLog = createChildLogger({ mode: "enhance-prompt" });
15083
+ if (ctx.capabilities.anthropicAuth?.status !== "authenticated") {
15084
+ enhanceLog.error("Not authenticated with Anthropic");
15085
+ ctx.connection.send({
15086
+ type: "error",
15087
+ code: "not_authenticated",
15088
+ message: "Not authenticated with Anthropic. Run 'claude auth login' or set ANTHROPIC_API_KEY.",
15089
+ requestId
15090
+ });
15091
+ processedRequestIds.delete(requestId);
15092
+ return;
15093
+ }
15094
+ const abortController = ctx.lifecycle.createAbortController();
15095
+ const timeout = setTimeout(() => abortController.abort(), ENHANCE_PROMPT_TIMEOUT_MS);
15096
+ runEnhancePrompt(prompt, requestId, abortController, ctx, enhanceLog).catch((err) => {
15097
+ enhanceLog.error({ err }, "Enhance prompt failed");
15098
+ }).finally(() => {
15099
+ clearTimeout(timeout);
15100
+ abortController.abort();
15101
+ processedRequestIds.delete(requestId);
15102
+ });
15103
+ }
15104
+ function extractTextChunks(rawContent) {
15105
+ if (!Array.isArray(rawContent)) return [];
15106
+ const chunks = [];
15107
+ for (const block of rawContent) {
15108
+ const b = block;
15109
+ if (b.type === "text" && typeof b.text === "string") {
15110
+ chunks.push(b.text);
15111
+ }
15112
+ }
15113
+ return chunks;
15114
+ }
15115
+ async function runEnhancePrompt(prompt, requestId, abortController, ctx, log) {
15116
+ let fullText = "";
15117
+ try {
15118
+ const response = query2({
15119
+ prompt,
15120
+ options: {
15121
+ maxTurns: 1,
15122
+ allowedTools: [],
15123
+ systemPrompt: ENHANCE_SYSTEM_PROMPT,
15124
+ abortController
15125
+ }
15126
+ });
15127
+ for await (const message of response) {
15128
+ if (message.type !== "assistant") continue;
15129
+ for (const text of extractTextChunks(message.message.content)) {
15130
+ fullText += text;
15131
+ ctx.connection.send({
15132
+ type: "enhance-prompt-chunk",
15133
+ requestId,
15134
+ text
15135
+ });
15136
+ }
15137
+ }
15138
+ ctx.connection.send({
15139
+ type: "enhance-prompt-done",
15140
+ requestId,
15141
+ fullText
15142
+ });
15143
+ log.info({ promptLen: prompt.length, resultLen: fullText.length }, "Prompt enhanced");
15144
+ } catch (err) {
15145
+ if (abortController.signal.aborted) {
15146
+ log.warn("Prompt enhancement aborted");
15147
+ } else {
15148
+ log.error({ err }, "Prompt enhancement failed");
15149
+ }
15150
+ ctx.connection.send({
15151
+ type: "error",
15152
+ code: "enhance_failed",
15153
+ message: err instanceof Error ? err.message : "Prompt enhancement failed",
15154
+ requestId
15155
+ });
15156
+ }
15157
+ }
15158
+ function handleWorktreeCreate(msg, ctx) {
15159
+ const { requestId, sourceRepoPath, branchName, baseRef } = msg;
15160
+ if (processedRequestIds.has(requestId)) return;
15161
+ processedRequestIds.add(requestId);
15162
+ const wtLog = createChildLogger({ mode: "worktree-create" });
15163
+ const roomJson = ctx.roomDoc.toJSON();
15164
+ const scriptEntry = roomJson?.userSettings?.worktreeScripts?.[sourceRepoPath];
15165
+ const setupScript = scriptEntry?.script ?? null;
15166
+ wtLog.info({ sourceRepoPath, branchName, baseRef }, "Starting worktree creation");
15167
+ runWorktreeCreate(requestId, sourceRepoPath, branchName, baseRef, setupScript, ctx, wtLog).catch(
15168
+ (err) => {
15169
+ wtLog.error({ err }, "Worktree create handler failed");
15170
+ }
15171
+ );
15172
+ }
15173
+ function monitorSetupChild(child, requestId, worktreePath, machineId, roomHandle, roomDoc, startedAt, log) {
15174
+ child.on("exit", (exitCode, signal) => {
15175
+ const status = exitCode === 0 ? "done" : "failed";
15176
+ log.info({ worktreePath, exitCode, signal, status }, "Setup script exited");
15177
+ change(roomDoc, (draft) => {
15178
+ draft.worktreeSetupStatus.set(worktreePath, {
15179
+ status,
15180
+ machineId,
15181
+ startedAt,
15182
+ completedAt: Date.now(),
15183
+ exitCode: exitCode ?? null,
15184
+ signal: signal ?? null,
15185
+ pid: child.pid ?? null
15186
+ });
15187
+ });
15188
+ try {
15189
+ roomHandle.worktreeSetupResps.set(requestId, {
15190
+ exitCode: exitCode ?? null,
15191
+ signal: signal ?? null,
15192
+ worktreePath
15193
+ });
15194
+ } catch (err) {
15195
+ log.warn({ err }, "Failed to publish setup result to ephemeral");
15196
+ }
15197
+ scheduleEphemeralCleanup(() => {
15198
+ roomHandle.worktreeSetupResps.delete(requestId);
15199
+ }, EPHEMERAL_CLEANUP_DELAY_MS);
15200
+ });
15201
+ child.on("error", (err) => {
15202
+ log.warn({ err: err.message }, "Setup script spawn error");
15203
+ change(roomDoc, (draft) => {
15204
+ draft.worktreeSetupStatus.set(worktreePath, {
15205
+ status: "failed",
15206
+ machineId,
15207
+ startedAt,
15208
+ completedAt: Date.now(),
15209
+ exitCode: null,
15210
+ signal: null,
15211
+ pid: child.pid ?? null
15212
+ });
15213
+ });
15214
+ roomHandle.worktreeSetupResps.set(requestId, {
15215
+ exitCode: null,
15216
+ signal: null,
15217
+ worktreePath
15218
+ });
15219
+ scheduleEphemeralCleanup(() => {
15220
+ roomHandle.worktreeSetupResps.delete(requestId);
15221
+ }, EPHEMERAL_CLEANUP_DELAY_MS);
15222
+ });
15223
+ child.unref();
15224
+ }
15225
+ async function runWorktreeCreate(requestId, sourceRepoPath, branchName, baseRef, setupScript, ctx, log) {
15226
+ try {
15227
+ const result = await createWorktree({
15228
+ sourceRepoPath,
15229
+ branchName,
15230
+ baseRef,
15231
+ setupScript,
15232
+ onProgress(step, detail) {
15233
+ ctx.connection.send({
15234
+ type: "worktree-create-progress",
15235
+ requestId,
15236
+ // eslint-disable-next-line no-restricted-syntax -- step string from createWorktree matches the schema enum
15237
+ step,
15238
+ detail
15239
+ });
15240
+ }
15241
+ });
15242
+ ctx.connection.send({
15243
+ type: "worktree-create-progress",
15244
+ requestId,
15245
+ step: "refreshing-environments",
15246
+ detail: "Re-detecting git environments"
15247
+ });
15248
+ try {
15249
+ const newEnvs = await detectEnvironments();
15250
+ ctx.capabilities.environments = newEnvs;
15251
+ ctx.publishCapabilities(ctx.capabilities);
15252
+ const repoMeta = await getRepoMetadata(result.worktreePath);
15253
+ if (repoMeta) {
15254
+ ctx.branchWatcher.addEnvironment(result.worktreePath, repoMeta.branch);
15255
+ }
15256
+ } catch (err) {
15257
+ log.warn({ err }, "Failed to refresh environments after worktree creation");
15258
+ }
15259
+ ctx.connection.send({
15260
+ type: "worktree-create-done",
15261
+ requestId,
15262
+ worktreePath: result.worktreePath,
15263
+ branchName: result.branchName,
15264
+ setupScriptStarted: result.setupScriptStarted,
15265
+ warnings: result.warnings.length > 0 ? result.warnings : void 0
15266
+ });
15267
+ if (result.setupChild) {
15268
+ const startedAt = Date.now();
15269
+ change(ctx.roomDoc, (draft) => {
15270
+ draft.worktreeSetupStatus.set(result.worktreePath, {
15271
+ status: "running",
15272
+ machineId: ctx.machineId,
15273
+ startedAt,
15274
+ completedAt: null,
15275
+ exitCode: null,
15276
+ signal: null,
15277
+ pid: result.setupChild?.pid ?? null
15278
+ });
15279
+ });
15280
+ monitorSetupChild(
15281
+ result.setupChild,
15282
+ requestId,
15283
+ result.worktreePath,
15284
+ ctx.machineId,
15285
+ ctx.roomHandle,
15286
+ ctx.roomDoc,
15287
+ startedAt,
15288
+ log
15289
+ );
15290
+ }
15291
+ log.info(
15292
+ {
15293
+ worktreePath: result.worktreePath,
15294
+ setupScriptStarted: result.setupScriptStarted,
15295
+ warningCount: result.warnings.length
15296
+ },
15297
+ "Worktree created successfully"
15298
+ );
15299
+ } catch (err) {
15300
+ const message = err instanceof Error ? err.message : String(err);
15301
+ log.error({ err: message }, "Worktree creation failed");
15302
+ ctx.connection.send({
15303
+ type: "worktree-create-error",
15304
+ requestId,
15305
+ message
15306
+ });
15307
+ } finally {
15308
+ processedRequestIds.delete(requestId);
15309
+ }
15310
+ }
15311
+ var EPHEMERAL_CLEANUP_DELAY_MS = 5e3;
15312
+ async function runEnhancePromptEphemeral(prompt, requestId, abortController, roomHandle, log) {
15313
+ let fullText = "";
15314
+ try {
15315
+ const response = query2({
15316
+ prompt,
15317
+ options: {
15318
+ maxTurns: 1,
15319
+ allowedTools: [],
15320
+ systemPrompt: ENHANCE_SYSTEM_PROMPT,
15321
+ abortController
15322
+ }
15323
+ });
15324
+ for await (const message of response) {
15325
+ if (message.type !== "assistant") continue;
15326
+ for (const text of extractTextChunks(message.message.content)) {
15327
+ fullText += text;
15328
+ roomHandle.enhancePromptResps.set(requestId, {
15329
+ status: "streaming",
15330
+ text: fullText,
15331
+ error: null
15332
+ });
15333
+ }
15334
+ }
15335
+ roomHandle.enhancePromptResps.set(requestId, {
15336
+ status: "done",
15337
+ text: fullText,
15338
+ error: null
15339
+ });
15340
+ log.info(
15341
+ { promptLen: prompt.length, resultLen: fullText.length },
15342
+ "Prompt enhanced via ephemeral"
15343
+ );
15344
+ } catch (err) {
15345
+ if (abortController.signal.aborted) {
15346
+ log.warn("Prompt enhancement aborted (ephemeral)");
15347
+ } else {
15348
+ log.error({ err }, "Prompt enhancement failed (ephemeral)");
15349
+ }
15350
+ const errorMessage = err instanceof Error ? err.message : "Prompt enhancement failed";
15351
+ roomHandle.enhancePromptResps.set(requestId, {
15352
+ status: "error",
15353
+ text: "",
15354
+ error: errorMessage
15355
+ });
15356
+ } finally {
15357
+ scheduleEphemeralCleanup(() => {
15358
+ roomHandle.enhancePromptReqs.delete(requestId);
15359
+ roomHandle.enhancePromptResps.delete(requestId);
15360
+ }, EPHEMERAL_CLEANUP_DELAY_MS);
15361
+ }
15362
+ }
15363
+ async function runWorktreeCreateEphemeral(requestId, sourceRepoPath, branchName, baseRef, setupScript, roomHandle, roomDoc, localMachineId, caps, publishCaps, watcher, log) {
15364
+ try {
15365
+ const result = await createWorktree({
15366
+ sourceRepoPath,
15367
+ branchName,
15368
+ baseRef,
15369
+ setupScript,
15370
+ onProgress(step, detail) {
15371
+ roomHandle.worktreeCreateResps.set(requestId, {
15372
+ // eslint-disable-next-line no-restricted-syntax -- step string from createWorktree matches the schema enum
15373
+ status: step,
15374
+ detail: detail ?? null,
15375
+ worktreePath: null,
15376
+ branchName: null,
15377
+ setupScriptStarted: null,
15378
+ warnings: null,
15379
+ error: null
15380
+ });
15381
+ }
15382
+ });
15383
+ roomHandle.worktreeCreateResps.set(requestId, {
15384
+ status: "refreshing-environments",
15385
+ detail: "Re-detecting git environments",
15386
+ worktreePath: null,
15387
+ branchName: null,
15388
+ setupScriptStarted: null,
15389
+ warnings: null,
15390
+ error: null
15391
+ });
15392
+ try {
15393
+ const newEnvs = await detectEnvironments();
15394
+ caps.environments = newEnvs;
15395
+ publishCaps(caps);
15396
+ const repoMeta = await getRepoMetadata(result.worktreePath);
15397
+ if (repoMeta) {
15398
+ watcher.addEnvironment(result.worktreePath, repoMeta.branch);
15399
+ }
15400
+ } catch (err) {
15401
+ log.warn({ err }, "Failed to refresh environments after worktree creation (ephemeral)");
15402
+ }
15403
+ roomHandle.worktreeCreateResps.set(requestId, {
15404
+ status: "done",
15405
+ detail: null,
15406
+ worktreePath: result.worktreePath,
15407
+ branchName: result.branchName,
15408
+ setupScriptStarted: result.setupScriptStarted,
15409
+ warnings: result.warnings.length > 0 ? result.warnings : null,
15410
+ error: null
15411
+ });
15412
+ if (result.setupChild) {
15413
+ const startedAt = Date.now();
15414
+ change(roomDoc, (draft) => {
15415
+ draft.worktreeSetupStatus.set(result.worktreePath, {
15416
+ status: "running",
15417
+ machineId: localMachineId,
15418
+ startedAt,
15419
+ completedAt: null,
15420
+ exitCode: null,
15421
+ signal: null,
15422
+ pid: result.setupChild?.pid ?? null
15423
+ });
15424
+ });
15425
+ monitorSetupChild(
15426
+ result.setupChild,
15427
+ requestId,
15428
+ result.worktreePath,
15429
+ localMachineId,
15430
+ roomHandle,
15431
+ roomDoc,
15432
+ startedAt,
15433
+ log
15434
+ );
15435
+ }
15436
+ log.info(
15437
+ {
15438
+ worktreePath: result.worktreePath,
15439
+ setupScriptStarted: result.setupScriptStarted,
15440
+ warningCount: result.warnings.length
15441
+ },
15442
+ "Worktree created successfully via ephemeral"
15443
+ );
15444
+ } catch (err) {
15445
+ const message = err instanceof Error ? err.message : String(err);
15446
+ log.error({ err: message }, "Worktree creation failed (ephemeral)");
15447
+ roomHandle.worktreeCreateResps.set(requestId, {
15448
+ status: "error",
15449
+ detail: null,
15450
+ worktreePath: null,
15451
+ branchName: null,
15452
+ setupScriptStarted: null,
15453
+ warnings: null,
15454
+ error: message
15455
+ });
15456
+ } finally {
15457
+ scheduleEphemeralCleanup(() => {
15458
+ roomHandle.worktreeCreateReqs.delete(requestId);
15459
+ roomHandle.worktreeCreateResps.delete(requestId);
15460
+ }, EPHEMERAL_CLEANUP_DELAY_MS);
15461
+ }
15462
+ }
13554
15463
  async function watchTaskDocument(taskId, taskLog, ctx) {
13555
15464
  const epoch = DEFAULT_EPOCH;
13556
15465
  const taskDocId = buildDocumentId("task", taskId, epoch);
@@ -13561,6 +15470,14 @@ async function watchTaskDocument(taskId, taskLog, ctx) {
13561
15470
  } catch {
13562
15471
  taskLog.debug({ taskDocId }, "No existing task data in storage");
13563
15472
  }
15473
+ try {
15474
+ await taskHandle.waitForSync({ kind: "network", timeout: 3e3 });
15475
+ } catch {
15476
+ taskLog.debug({ taskDocId }, "Network sync timed out (browser may not be connected yet)");
15477
+ }
15478
+ if (recoverOrphanedTask(taskHandle.doc, taskLog)) {
15479
+ updateTaskInIndex(ctx.roomDoc, taskId, { status: "failed", updatedAt: Date.now() });
15480
+ }
13564
15481
  const json = taskHandle.doc.toJSON();
13565
15482
  const lastUserMsg = [...json.conversation].reverse().find((m) => m.role === "user");
13566
15483
  const initialCwd = lastUserMsg?.cwd ?? process.cwd();
@@ -13571,10 +15488,10 @@ async function watchTaskDocument(taskId, taskLog, ctx) {
13571
15488
  taskLog.info({ taskDocId, opCount: opCountBefore }, "Doc state before subscribe");
13572
15489
  const unsubscribe = taskHandle.subscribe((event) => {
13573
15490
  taskLog.info({ taskDocId, eventBy: event.by }, "Subscription event received");
13574
- if (event.by === "local") return;
13575
15491
  onTaskDocChanged(taskId, taskHandle, taskLog, ctx);
13576
15492
  });
13577
15493
  ctx.watchedTasks.set(taskId, unsubscribe);
15494
+ ctx.taskHandles.set(taskId, taskHandle);
13578
15495
  taskLog.info({ taskDocId }, "Subscribed to task document changes");
13579
15496
  const opCountAfter = taskHandle.loroDoc.opCount();
13580
15497
  taskLog.info({ taskDocId, opCount: opCountAfter }, "Doc state after subscribe");
@@ -13587,17 +15504,25 @@ async function watchTaskDocument(taskId, taskLog, ctx) {
13587
15504
  onTaskDocChanged(taskId, taskHandle, taskLog, ctx);
13588
15505
  }
13589
15506
  }
13590
- function handleFollowUp(activeTask, taskLog) {
15507
+ function handleFollowUp(activeTask, conversationLen, taskLog) {
13591
15508
  if (!activeTask) return;
15509
+ if (conversationLen <= activeTask.lastDispatchedConvLen) {
15510
+ taskLog.debug(
15511
+ { conversationLen, lastDispatched: activeTask.lastDispatchedConvLen },
15512
+ "Conversation unchanged since last dispatch, skipping duplicate"
15513
+ );
15514
+ return;
15515
+ }
13592
15516
  if (!activeTask.sessionManager.isStreaming) {
13593
15517
  taskLog.debug("Task already running but not streaming, skipping");
13594
15518
  return;
13595
15519
  }
13596
- const prompt = activeTask.sessionManager.getLatestUserPrompt();
13597
- if (prompt) {
15520
+ const contentBlocks = activeTask.sessionManager.getLatestUserContentBlocks();
15521
+ if (contentBlocks && contentBlocks.length > 0) {
13598
15522
  try {
13599
15523
  taskLog.info("Sending follow-up to active streaming session");
13600
- activeTask.sessionManager.sendFollowUp(prompt);
15524
+ activeTask.lastDispatchedConvLen = conversationLen;
15525
+ activeTask.sessionManager.sendFollowUp(contentBlocks);
13601
15526
  } catch (err) {
13602
15527
  taskLog.warn({ err }, "Failed to send follow-up to streaming session");
13603
15528
  }
@@ -13619,7 +15544,7 @@ function onTaskDocChanged(taskId, taskHandle, taskLog, ctx) {
13619
15544
  const conversation2 = json.conversation;
13620
15545
  const lastMessage2 = conversation2[conversation2.length - 1];
13621
15546
  if (lastMessage2?.role === "user") {
13622
- handleFollowUp(ctx.activeTasks.get(taskId), taskLog);
15547
+ handleFollowUp(ctx.activeTasks.get(taskId), conversation2.length, taskLog);
13623
15548
  }
13624
15549
  const activeLastUserMsg = [...conversation2].reverse().find((m) => m.role === "user");
13625
15550
  const activeCwd = activeLastUserMsg?.cwd ?? process.cwd();
@@ -13650,7 +15575,12 @@ function onTaskDocChanged(taskId, taskHandle, taskLog, ctx) {
13650
15575
  updateTaskInIndex(ctx.roomDoc, taskId, { status, updatedAt: Date.now() });
13651
15576
  };
13652
15577
  const manager = new SessionManager(doc, onStatusChange);
13653
- const activeTask = { taskId, abortController, sessionManager: manager };
15578
+ const activeTask = {
15579
+ taskId,
15580
+ abortController,
15581
+ sessionManager: manager,
15582
+ lastDispatchedConvLen: conversation.length
15583
+ };
13654
15584
  ctx.activeTasks.set(taskId, activeTask);
13655
15585
  ctx.signaling.updateStatus("running", taskId);
13656
15586
  const turnStartRefPromise = captureTreeSnapshot(cwd);
@@ -13689,6 +15619,8 @@ function onTaskDocChanged(taskId, taskHandle, taskLog, ctx) {
13689
15619
  turnStartRefPromise,
13690
15620
  abortController,
13691
15621
  ctx
15622
+ }).catch((cleanupErr) => {
15623
+ taskLog.warn({ err: cleanupErr }, "cleanupTaskRun failed");
13692
15624
  })
13693
15625
  );
13694
15626
  }
@@ -13722,11 +15654,6 @@ async function cleanupTaskRun(opts) {
13722
15654
  taskHandle.permResps.delete(key);
13723
15655
  }
13724
15656
  ctx.activeTasks.delete(taskId);
13725
- const unsub = ctx.watchedTasks.get(taskId);
13726
- if (unsub) {
13727
- unsub();
13728
- ctx.watchedTasks.delete(taskId);
13729
- }
13730
15657
  ctx.signaling.updateStatus("idle");
13731
15658
  }
13732
15659
  function clearDebouncedTimer(timers, taskId) {
@@ -13763,6 +15690,44 @@ function toPermissionResult(decision, input, suggestions, message) {
13763
15690
  message: message ?? "User denied permission"
13764
15691
  };
13765
15692
  }
15693
+ function resolveExitPlanMode(taskHandle, taskLog, toolUseID, value) {
15694
+ const plans = taskHandle.doc.toJSON().plans;
15695
+ const planIndex = plans.findIndex((p) => p.toolUseId === toolUseID);
15696
+ if (planIndex < 0) return;
15697
+ const plan = plans[planIndex];
15698
+ if (!plan) return;
15699
+ const reviewStatus = value.decision === "approved" ? "approved" : "changes-requested";
15700
+ const editedMarkdown = serializePlanEditorDoc(taskHandle.loroDoc, plan.planId);
15701
+ const allComments = taskHandle.doc.toJSON().planComments;
15702
+ const planComments = Object.values(allComments).filter(
15703
+ (c) => c.planId === plan.planId && c.resolvedAt === null
15704
+ );
15705
+ const richFeedback = formatPlanFeedbackForClaudeCode(
15706
+ plan.markdown,
15707
+ editedMarkdown || plan.markdown,
15708
+ planComments,
15709
+ value.message ?? null
15710
+ );
15711
+ change(taskHandle.doc, (draft) => {
15712
+ const draftPlan = draft.plans.get(planIndex);
15713
+ if (draftPlan) {
15714
+ draftPlan.reviewStatus = reviewStatus;
15715
+ draftPlan.reviewFeedback = (richFeedback || value.message) ?? null;
15716
+ }
15717
+ });
15718
+ if (richFeedback) {
15719
+ value.message = richFeedback;
15720
+ }
15721
+ taskLog.info(
15722
+ {
15723
+ toolUseID,
15724
+ reviewStatus,
15725
+ hasFeedback: !!richFeedback,
15726
+ hasEdits: editedMarkdown !== plan.markdown
15727
+ },
15728
+ "Updated plan reviewStatus in CRDT with rich feedback"
15729
+ );
15730
+ }
13766
15731
  function resolvePermissionResponse(ctx) {
13767
15732
  const { taskHandle, roomDoc, taskId, taskLog, toolName, toolUseID, input, suggestions, value } = ctx;
13768
15733
  taskHandle.permReqs.delete(toolUseID);
@@ -13773,22 +15738,7 @@ function resolvePermissionResponse(ctx) {
13773
15738
  });
13774
15739
  updateTaskInIndex(roomDoc, taskId, { status: "working", updatedAt: Date.now() });
13775
15740
  if (toolName === "ExitPlanMode") {
13776
- const plans = taskHandle.doc.toJSON().plans;
13777
- const planIndex = plans.findIndex((p) => p.toolUseId === toolUseID);
13778
- if (planIndex >= 0) {
13779
- const reviewStatus = value.decision === "approved" ? "approved" : "changes-requested";
13780
- change(taskHandle.doc, (draft) => {
13781
- const plan = draft.plans.get(planIndex);
13782
- if (plan) {
13783
- plan.reviewStatus = reviewStatus;
13784
- plan.reviewFeedback = value.message ?? null;
13785
- }
13786
- });
13787
- taskLog.info(
13788
- { toolUseID, reviewStatus, hasFeedback: !!value.message },
13789
- "Updated plan reviewStatus in CRDT"
13790
- );
13791
- }
15741
+ resolveExitPlanMode(taskHandle, taskLog, toolUseID, value);
13792
15742
  }
13793
15743
  taskLog.info(
13794
15744
  {
@@ -13837,19 +15787,35 @@ function buildCanUseTool(taskHandle, taskLog, roomDoc, taskId) {
13837
15787
  "Permission request sent to browser"
13838
15788
  );
13839
15789
  return new Promise((resolve3) => {
15790
+ const PERMISSION_TIMEOUT_MS = 5 * 60 * 1e3;
15791
+ let settled = false;
13840
15792
  let unsub;
13841
- const onAbort = () => {
15793
+ const settle = (result) => {
15794
+ if (settled) return;
15795
+ settled = true;
15796
+ clearTimeout(timeout);
13842
15797
  unsub?.();
15798
+ signal.removeEventListener("abort", onAbort);
13843
15799
  taskHandle.permReqs.delete(toolUseID);
13844
- resolve3({ behavior: "deny", message: "Task was aborted" });
15800
+ change(taskHandle.doc, (draft) => {
15801
+ draft.meta.status = "working";
15802
+ draft.meta.updatedAt = Date.now();
15803
+ });
15804
+ updateTaskInIndex(roomDoc, taskId, { status: "working", updatedAt: Date.now() });
15805
+ resolve3(result);
15806
+ };
15807
+ const timeout = setTimeout(() => {
15808
+ taskLog.warn({ toolName, toolUseID }, "Permission request timed out");
15809
+ settle({ behavior: "deny", message: "Permission request timed out" });
15810
+ }, PERMISSION_TIMEOUT_MS);
15811
+ const onAbort = () => {
15812
+ settle({ behavior: "deny", message: "Task was aborted" });
13845
15813
  };
13846
15814
  signal.addEventListener("abort", onAbort, { once: true });
13847
15815
  unsub = taskHandle.permResps.subscribe(({ key, value, source }) => {
13848
15816
  if (source === "local") return;
13849
15817
  if (key !== toolUseID || !value) return;
13850
- unsub?.();
13851
- signal.removeEventListener("abort", onAbort);
13852
- resolve3(
15818
+ settle(
13853
15819
  resolvePermissionResponse({
13854
15820
  taskHandle,
13855
15821
  roomDoc,
@@ -13880,11 +15846,16 @@ async function runTask(opts) {
13880
15846
  abortController,
13881
15847
  log
13882
15848
  } = opts;
13883
- const prompt = manager.getLatestUserPrompt();
13884
- if (!prompt) {
15849
+ const contentBlocks = manager.getLatestUserContentBlocks();
15850
+ if (!contentBlocks || contentBlocks.length === 0) {
13885
15851
  throw new Error(`No user message found in task ${taskId}`);
13886
15852
  }
13887
- log.info({ prompt: prompt.slice(0, 100) }, "Running task with prompt from CRDT");
15853
+ const textPreview = contentBlocks.filter((b) => b.type === "text").map((b) => b.text).join(" ").slice(0, 100);
15854
+ const imageCount = contentBlocks.filter((b) => b.type === "image").length;
15855
+ log.info(
15856
+ { prompt: textPreview || "(images only)", imageCount },
15857
+ "Running task with prompt from CRDT"
15858
+ );
13888
15859
  const canUseTool = permissionMode === "bypassPermissions" ? void 0 : buildCanUseTool(taskHandle, log, roomDoc, taskId);
13889
15860
  const stderr = (data) => {
13890
15861
  const trimmed = data.trim();
@@ -13898,7 +15869,7 @@ async function runTask(opts) {
13898
15869
  const resumeInfo = manager.shouldResume();
13899
15870
  if (resumeInfo.resume && resumeInfo.sessionId) {
13900
15871
  log.info({ sessionId: resumeInfo.sessionId }, "Resuming existing session");
13901
- return manager.resumeSession(resumeInfo.sessionId, prompt, {
15872
+ return manager.resumeSession(resumeInfo.sessionId, contentBlocks, {
13902
15873
  abortController,
13903
15874
  machineId,
13904
15875
  model,
@@ -13910,7 +15881,7 @@ async function runTask(opts) {
13910
15881
  });
13911
15882
  }
13912
15883
  return manager.createSession({
13913
- prompt,
15884
+ prompt: contentBlocks,
13914
15885
  cwd,
13915
15886
  machineId,
13916
15887
  model,
@@ -13962,8 +15933,12 @@ function parseCliArgs() {
13962
15933
  " -s, --serve Run in serve mode (signaling + spawn-agent)",
13963
15934
  " -h, --help Show this help",
13964
15935
  "",
15936
+ "Authentication:",
15937
+ " Run `claude auth login` to authenticate with Anthropic (primary method).",
15938
+ " Alternatively, set ANTHROPIC_API_KEY for CI/headless environments.",
15939
+ "",
13965
15940
  "Environment:",
13966
- " ANTHROPIC_API_KEY API key for Claude (required for task mode)",
15941
+ " ANTHROPIC_API_KEY API key for Claude (optional, overrides OAuth)",
13967
15942
  " SHIPYARD_DEV Set to 1 for dev mode (uses ~/.shipyard-dev/)",
13968
15943
  " SHIPYARD_DATA_DIR Data directory (overridden by --data-dir)",
13969
15944
  " LOG_LEVEL Log level: debug, info, warn, error (default: info)",
@@ -13994,7 +15969,7 @@ async function setupSignaling(env, log) {
13994
15969
  return handle;
13995
15970
  }
13996
15971
  async function setupRepo(dataDir) {
13997
- await mkdir3(dataDir, { recursive: true, mode: 448 });
15972
+ await mkdir4(dataDir, { recursive: true, mode: 448 });
13998
15973
  const storage = new FileStorageAdapter(dataDir);
13999
15974
  const repo = new Repo({
14000
15975
  identity: { name: "shipyard-daemon" },
@@ -14061,13 +16036,13 @@ function handleResult(log, result, startTime) {
14061
16036
  async function handleSubcommand() {
14062
16037
  const subcommand = process.argv[2];
14063
16038
  if (subcommand === "login") {
14064
- const { loginCommand } = await import("./login-YE3CJW2C.js");
16039
+ const { loginCommand } = await import("./login-625HP2EN.js");
14065
16040
  const hasCheck = process.argv.includes("--check");
14066
16041
  await loginCommand({ check: hasCheck });
14067
16042
  return true;
14068
16043
  }
14069
16044
  if (subcommand === "logout") {
14070
- const { logoutCommand } = await import("./logout-5JRAQEQC.js");
16045
+ const { logoutCommand } = await import("./logout-L6EMSGQU.js");
14071
16046
  await logoutCommand();
14072
16047
  return true;
14073
16048
  }
@@ -14075,7 +16050,7 @@ async function handleSubcommand() {
14075
16050
  }
14076
16051
  async function loadAuthFromConfig(env) {
14077
16052
  if (env.SHIPYARD_USER_TOKEN) return;
14078
- const { loadAuthToken } = await import("./auth-BJMJG73E.js");
16053
+ const { loadAuthToken } = await import("./auth-YRHZ3SMD.js");
14079
16054
  const auth = await loadAuthToken();
14080
16055
  if (auth.status === "ok") {
14081
16056
  env.SHIPYARD_USER_TOKEN = auth.token;
@@ -14091,15 +16066,11 @@ async function loadAuthFromConfig(env) {
14091
16066
  }
14092
16067
  logger.warn("No auth token found. Run `shipyard login` to authenticate.");
14093
16068
  }
14094
- function validateTaskArgs(args, env) {
16069
+ function validateTaskArgs(args) {
14095
16070
  if (!args.prompt && !args.resume) {
14096
16071
  logger.error("Either --prompt, --resume, or --serve is required. Use --help for usage.");
14097
16072
  process.exit(1);
14098
16073
  }
14099
- if (!env.ANTHROPIC_API_KEY) {
14100
- logger.error("ANTHROPIC_API_KEY is required when running tasks. Use --help for usage.");
14101
- process.exit(1);
14102
- }
14103
16074
  }
14104
16075
  function createCleanup(signalingHandle, lifecycle, repo) {
14105
16076
  let cleanedUp = false;
@@ -14126,13 +16097,14 @@ async function main() {
14126
16097
  if (args.serve) {
14127
16098
  return serve(env);
14128
16099
  }
14129
- validateTaskArgs(args, env);
16100
+ validateTaskArgs(args);
14130
16101
  const dataDir = resolve2(args.dataDir ?? env.SHIPYARD_DATA_DIR.replace("~", homedir3()));
14131
16102
  const taskId = args.taskId ?? generateTaskId();
14132
16103
  const log = createChildLogger({ taskId });
14133
16104
  log.info({ dataDir, prompt: args.prompt, resume: args.resume }, "Starting daemon");
14134
16105
  const repo = await setupRepo(dataDir);
14135
16106
  const lifecycle = new LifecycleManager();
16107
+ await lifecycle.acquirePidFile(getShipyardHome());
14136
16108
  const signalingHandle = await setupSignaling(env, log);
14137
16109
  const cleanup = createCleanup(signalingHandle, lifecycle, repo);
14138
16110
  lifecycle.onShutdown(async () => {