runcastle 1.4.4 → 1.4.6

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/bin/runcastle.js CHANGED
@@ -45,7 +45,7 @@ function agentDigestFillRank(relPath) {
45
45
  const i = AGENT_DIGEST_FILL_ORDER.indexOf(relPath.toLowerCase());
46
46
  return i === -1 ? AGENT_DIGEST_FILL_ORDER.length : i;
47
47
  }
48
- var AGENT_DIGEST_DOCS, WITHHELD_FEATURE_DOCS, AGENT_DIGEST_FILL_ORDER;
48
+ var AGENT_DIGEST_DOCS, PREVIEW_FILE = "preview.html", WITHHELD_FEATURE_DOCS, AGENT_DIGEST_FILL_ORDER;
49
49
  var init_docs = __esm(() => {
50
50
  AGENT_DIGEST_DOCS = ["brief.md", "decisions.md", "spec.md"];
51
51
  WITHHELD_FEATURE_DOCS = {
@@ -264,7 +264,7 @@ var init_schemas = __esm(() => {
264
264
  "drive-fix"
265
265
  ]);
266
266
  PROJECT_SESSION_KINDS = ["prepare", "project"];
267
- SessionPurpose = z3.enum(["resolve-conflict", "start-lap"]);
267
+ SessionPurpose = z3.enum(["resolve-conflict", "start-lap", "self-plan"]);
268
268
  MergeBranchPair = z3.object({
269
269
  mergeFrom: z3.string(),
270
270
  mergeInto: z3.string()
@@ -1659,8 +1659,9 @@ ${why.replace(/\n/g, `\r
1659
1659
  containment?.kill();
1660
1660
  fireExit(code ?? 0, signal ? 1 : undefined);
1661
1661
  });
1662
+ child.stdin?.on("error", () => {});
1662
1663
  function sendToHost(obj) {
1663
- if (!child.stdin || child.stdin.destroyed)
1664
+ if (exitFired || !child.stdin || child.stdin.destroyed)
1664
1665
  return;
1665
1666
  try {
1666
1667
  child.stdin.write(JSON.stringify(obj) + `
@@ -2627,7 +2628,7 @@ function noteDocsChange(ctx, featureId, filename) {
2627
2628
  const entry = watches.get(featureId);
2628
2629
  if (!entry)
2629
2630
  return;
2630
- if (filename !== null && !filename.endsWith(".md"))
2631
+ if (filename !== null && !filename.endsWith(".md") && filename !== PREVIEW_FILE)
2631
2632
  return;
2632
2633
  if (filename)
2633
2634
  entry.changed.add(filename);
@@ -2654,6 +2655,7 @@ function emitDocsChanged(ctx, featureId) {
2654
2655
  }
2655
2656
  var DOCS_WATCH_DEBOUNCE_MS = 300, watches;
2656
2657
  var init_docs_watch = __esm(() => {
2658
+ init_src();
2657
2659
  init_events();
2658
2660
  init_feature_docs();
2659
2661
  init_repo();
@@ -3576,6 +3578,9 @@ function chatBranchName(slug, unique) {
3576
3578
  function burnWorktreePath(repoPath, branch) {
3577
3579
  return join6(repoPath, ".sandcastle", "worktrees", branch.replace(/\//g, "-"));
3578
3580
  }
3581
+ async function excludeBurnWorktrees(repoPath) {
3582
+ await excludePath(repoPath, BURN_WORKTREES_PATTERN);
3583
+ }
3579
3584
  async function excludeFilePath(repoPath) {
3580
3585
  const raw = (await git(repoPath).raw(["rev-parse", "--git-path", "info/exclude"])).trim();
3581
3586
  return resolve3(repoPath, raw);
@@ -4616,7 +4621,7 @@ async function mergeInProgress(g) {
4616
4621
  return false;
4617
4622
  }
4618
4623
  }
4619
- var DOCS_PATHSPEC = "docs/features", MIGRATION_DIR_RE, DRIZZLE_SQL_RE, DENY_DIRTY = "Working tree has uncommitted changes \u2014 commit or stash first", DENY_ACTIVE = "A test drive is already active \u2014 stop it first", DENY_ACTIVE_RUN = "Feature has an active run \u2014 wait for it to finish", DENY_NONE_ACTIVE = "No test drive is active", DENY_DRY_RUN_ACTIVE = "A preparation dry-run is in progress \u2014 stop it first", DENY_NO_DRY_RUN = "No preparation dry-run is in progress", DENY_NO_REVIEW_DRIVE = "No review drive is in progress for this feature", INITIAL_COMMIT_SUBJECT = "runcastle: initial commit", TICKET_BRANCH_PREFIX = "runcastle/ticket/", CHAT_BRANCH_PREFIX = "runcastle/chat/", PROJECT_BRANCH = "runcastle/project", TEMP_BRANCH_PREFIXES, TEMP_BRANCH_SLUG_MAX = 16, testDriveState, reviewDriveRelease, readinessTiming, DRY_RUN_SLUG = "prep-dry-run", PROJECT_DRIVE_SLUG = "project-drive", DENY_NO_PROJECT_DRIVE = "No project drive is running for this project";
4624
+ var DOCS_PATHSPEC = "docs/features", MIGRATION_DIR_RE, DRIZZLE_SQL_RE, DENY_DIRTY = "Working tree has uncommitted changes \u2014 commit or stash first", DENY_ACTIVE = "A test drive is already active \u2014 stop it first", DENY_ACTIVE_RUN = "Feature has an active run \u2014 wait for it to finish", DENY_NONE_ACTIVE = "No test drive is active", DENY_DRY_RUN_ACTIVE = "A preparation dry-run is in progress \u2014 stop it first", DENY_NO_DRY_RUN = "No preparation dry-run is in progress", DENY_NO_REVIEW_DRIVE = "No review drive is in progress for this feature", INITIAL_COMMIT_SUBJECT = "runcastle: initial commit", TICKET_BRANCH_PREFIX = "runcastle/ticket/", CHAT_BRANCH_PREFIX = "runcastle/chat/", PROJECT_BRANCH = "runcastle/project", TEMP_BRANCH_PREFIXES, TEMP_BRANCH_SLUG_MAX = 16, BURN_WORKTREES_PATTERN = ".sandcastle/worktrees/", testDriveState, reviewDriveRelease, readinessTiming, DRY_RUN_SLUG = "prep-dry-run", PROJECT_DRIVE_SLUG = "project-drive", DENY_NO_PROJECT_DRIVE = "No project drive is running for this project";
4620
4625
  var init_git = __esm(() => {
4621
4626
  init_src();
4622
4627
  init_paths();
@@ -4774,6 +4779,9 @@ function entrySkillsFor(kind) {
4774
4779
  function chatKickoffFor(runtime, opening) {
4775
4780
  return opening === "ideate" ? `Proceed with your task: invoke the ${skillRef(runtime, "ideate")} skill and drive this ` + "feature's ideation to completion." : `Proceed with your task: invoke the ${skillRef(runtime, "revisit")} skill and continue this ` + "feature's conversation.";
4776
4781
  }
4782
+ function selfPlanKickoffFor(runtime) {
4783
+ return "Proceed with your task: no human is present and nobody will reply to you. Invoke the " + `${skillRef(runtime, "ideate")} skill and follow its "${PLANNING_WITHOUT_A_HUMAN}" section ` + "to plan this feature by yourself, through the preview, the spec and the tickets, in this one run.";
4784
+ }
4777
4785
  function kickoffLinesFor(runtime) {
4778
4786
  const skill = (name) => skillRef(runtime, name);
4779
4787
  return {
@@ -4789,7 +4797,7 @@ function projectTriageKickoffFor(runtime) {
4789
4797
  function prepareConfirmKickoffFor(runtime) {
4790
4798
  return `Proceed with your task: invoke the ${skillRef(runtime, "prepare")} skill. Every prepared field already ` + "has a value, so this is a confirmation, not a preparation \u2014 tell me what is recorded and " + "how stale it is, ask whether it still holds, and stop. Do not re-derive a settled value.";
4791
4799
  }
4792
- var PACK = "runcastle", SPELLINGS, ENTRY_SKILLS;
4800
+ var PACK = "runcastle", SPELLINGS, ENTRY_SKILLS, PLANNING_WITHOUT_A_HUMAN = "Planning without a human";
4793
4801
  var init_skills = __esm(() => {
4794
4802
  SPELLINGS = {
4795
4803
  "claude-code": (skill) => `/${PACK}:${skill}`,
@@ -4830,7 +4838,8 @@ function chatOpening(feature, planning) {
4830
4838
  function renderSystemPrompt(feature, kind, lap, purpose, runtime = DEFAULT_RUNTIME, carried, planning) {
4831
4839
  if (lap !== undefined)
4832
4840
  return renderRevisitPrompt(feature, lap, purpose, runtime, carried);
4833
- if (kind === "chat" && chatOpening(feature, planning) === "revisit") {
4841
+ const selfPlan = purpose === "self-plan";
4842
+ if (kind === "chat" && !selfPlan && chatOpening(feature, planning) === "revisit") {
4834
4843
  return renderRevisitPrompt(feature, lap, purpose, runtime, carried);
4835
4844
  }
4836
4845
  const docs2 = featureDocsRel(feature.slug);
@@ -4863,12 +4872,26 @@ function renderSystemPrompt(feature, kind, lap, purpose, runtime = DEFAULT_RUNTI
4863
4872
  noCodeRule(docs2),
4864
4873
  otherEntrySkillsRule(),
4865
4874
  "",
4875
+ ...selfPlan ? [selfPlanRule(), ""] : [],
4866
4876
  "## Your task",
4867
4877
  `Begin by invoking the \`${skillRef(runtime, "ideate")}\` skill and drive the ideation session to completion.`,
4868
4878
  ""
4869
4879
  ].join(`
4870
4880
  `);
4871
4881
  }
4882
+ function selfPlanRule() {
4883
+ return [
4884
+ "## No human is present",
4885
+ "This is a self-planning run: nobody is watching and nobody will reply.",
4886
+ "- Never end your turn to ask a question \u2014 ending the turn ends the run. Ask your design",
4887
+ " questions of yourself, as the skill describes, and answer them.",
4888
+ "- A refused tool is not a wait: do not retry it or wait for approval. Decide on what you",
4889
+ " know and treat that decision as a candidate to check first.",
4890
+ `- Follow the ideation skill's "${PLANNING_WITHOUT_A_HUMAN}" section through the preview, the`,
4891
+ " spec and the tickets, in this one run."
4892
+ ].join(`
4893
+ `);
4894
+ }
4872
4895
  function reviewEvidenceSection(lap, docs2, carried) {
4873
4896
  const evidence = carried?.reviewEvidence ?? [];
4874
4897
  return [
@@ -5452,6 +5475,10 @@ var init_skills_root = __esm(() => {
5452
5475
  });
5453
5476
 
5454
5477
  // src/launcher/runtimes/types.ts
5478
+ function assertHeadlessKickoff(headless, line) {
5479
+ if (headless && !line)
5480
+ throw new Error("a headless launch needs a kickoff line as its prompt");
5481
+ }
5455
5482
  function assertKickoffArgv(line) {
5456
5483
  if (line !== undefined && line.length > MAX_KICKOFF_ARGV) {
5457
5484
  throw new Error(`kickoff line is ${line.length} characters; maximum is ${MAX_KICKOFF_ARGV}`);
@@ -5462,9 +5489,11 @@ var MAX_KICKOFF_ARGV = 30000;
5462
5489
  // src/launcher/runtimes/claude.ts
5463
5490
  function buildClaudeArgs(input) {
5464
5491
  assertKickoffArgv(input.kickoffLine);
5492
+ assertHeadlessKickoff(input.headless, input.kickoffLine);
5465
5493
  const permissionMode = input.permissionMode ?? "acceptEdits";
5466
- const resume = input.resumeSessionId ? ["--resume", input.resumeSessionId] : [];
5494
+ const resume = input.resumeSessionId && !input.headless ? ["--resume", input.resumeSessionId] : [];
5467
5495
  return [
5496
+ ...input.headless ? ["-p"] : [],
5468
5497
  ...resume,
5469
5498
  "--settings",
5470
5499
  input.settingsPath,
@@ -5527,7 +5556,8 @@ var init_claude = __esm(() => {
5527
5556
  model: input.model,
5528
5557
  ...input.resumeSessionId ? { resumeSessionId: input.resumeSessionId } : {},
5529
5558
  ...input.kickoffLine ? { kickoffLine: input.kickoffLine } : {},
5530
- strictMcp: input.config.sessionMcp === "runcastleOnly"
5559
+ strictMcp: input.config.sessionMcp === "runcastleOnly",
5560
+ ...input.headless ? { headless: true } : {}
5531
5561
  }),
5532
5562
  env: {
5533
5563
  RUNCASTLE_SESSION_ID: input.session.id,
@@ -7137,12 +7167,16 @@ function toml(value) {
7137
7167
  function approvalPolicyFor(permissionMode) {
7138
7168
  return permissionMode === undefined || permissionMode === "acceptEdits" ? "never" : "on-request";
7139
7169
  }
7170
+ function sandboxModeFor(permissionMode, platform) {
7171
+ return platform === "win32" && approvalPolicyFor(permissionMode) === "never" ? "danger-full-access" : "workspace-write";
7172
+ }
7140
7173
  function renderCodexConfig(input) {
7141
7174
  const { session, config: config2, model, worktreePath, inheritedMcpServers, permissionMode } = input;
7175
+ const platform = input.platform ?? process.platform;
7142
7176
  return [
7143
7177
  `# Generated by runcastle for session ${session.id} \u2014 rewritten at every launch.`,
7144
7178
  `model = ${toml(model)}`,
7145
- 'sandbox_mode = "workspace-write"',
7179
+ `sandbox_mode = ${toml(sandboxModeFor(permissionMode, platform))}`,
7146
7180
  `approval_policy = ${toml(approvalPolicyFor(permissionMode))}`,
7147
7181
  "check_for_update_on_startup = false",
7148
7182
  "",
@@ -7156,6 +7190,7 @@ function renderCodexConfig(input) {
7156
7190
  "[mcp_servers.runcastle]",
7157
7191
  `url = ${toml(`${serverUrlFor(config2)}/mcp`)}`,
7158
7192
  `http_headers = { "X-Runcastle-Session" = ${toml(session.id)} }`,
7193
+ 'default_tools_approval_mode = "approve"',
7159
7194
  ""
7160
7195
  ].join(`
7161
7196
  `);
@@ -7264,6 +7299,9 @@ function gitInfoDir(worktreePath) {
7264
7299
  }
7265
7300
  function buildCodexArgs(input) {
7266
7301
  assertKickoffArgv(input.kickoffLine);
7302
+ assertHeadlessKickoff(input.headless, input.kickoffLine);
7303
+ if (input.headless)
7304
+ return ["exec", "--dangerously-bypass-hook-trust", input.kickoffLine];
7267
7305
  return [
7268
7306
  ...input.resumeSessionId ? ["resume", input.resumeSessionId] : [],
7269
7307
  "--dangerously-bypass-hook-trust",
@@ -7336,7 +7374,8 @@ var init_codex = __esm(() => {
7336
7374
  files,
7337
7375
  argv: buildCodexArgs({
7338
7376
  ...input.resumeSessionId ? { resumeSessionId: input.resumeSessionId } : {},
7339
- ...input.kickoffLine ? { kickoffLine: input.kickoffLine } : {}
7377
+ ...input.kickoffLine ? { kickoffLine: input.kickoffLine } : {},
7378
+ ...input.headless ? { headless: true } : {}
7340
7379
  }),
7341
7380
  env: {
7342
7381
  CODEX_HOME: home,
@@ -7971,6 +8010,12 @@ function listDocs(ctx, feature) {
7971
8010
  return { relPath: e.name, title: readTitle(path, e.name), ...writtenAt(path) };
7972
8011
  }).sort((a, b) => a.relPath.localeCompare(b.relPath));
7973
8012
  }
8013
+ function previewSummary(ctx, feature) {
8014
+ const path = featureDocPath(projectForFeature(ctx, feature), feature, PREVIEW_FILE);
8015
+ if (!existsSync12(path))
8016
+ return null;
8017
+ return { relPath: PREVIEW_FILE, ...writtenAt(path) };
8018
+ }
7974
8019
  function readDoc(ctx, feature, relPath) {
7975
8020
  const project = projectForFeature(ctx, feature);
7976
8021
  const dir = featureDocsDir(project, feature);
@@ -8003,6 +8048,7 @@ function readTitle(path, fallback) {
8003
8048
  }
8004
8049
  var CHARTER_FILE = "CONTEXT.md", ADR_DIR_REL = "docs/adr", SUPERSEDED_RE;
8005
8050
  var init_knowledge = __esm(() => {
8051
+ init_src();
8006
8052
  init_errors();
8007
8053
  init_events();
8008
8054
  init_feature_docs();
@@ -57791,6 +57837,7 @@ async function runReviewGates(input) {
57791
57837
  mkdirSync10(reviewGateLogDir(ticketId), { recursive: true });
57792
57838
  await discardBranch();
57793
57839
  await pinBranchAt(project3.repoPath, branch, sha);
57840
+ await excludeBurnWorktrees(project3.repoPath);
57794
57841
  while (results.length < commands.length) {
57795
57842
  await withSandbox(branch, async (sandbox3) => {
57796
57843
  for (const [i, command] of commands.entries()) {
@@ -60553,6 +60600,7 @@ async function burnTicket(ctx, ticket, config2, token, model, hostAgentVersions,
60553
60600
  await clearAttachments(burnWorktreePath(project3.repoPath, branch), project3.repoPath);
60554
60601
  }
60555
60602
  };
60603
+ await excludeBurnWorktrees(project3.repoPath);
60556
60604
  const ticketJson = buildTicketJson(ticket);
60557
60605
  const featureBrief = buildFeatureBrief(feature);
60558
60606
  const docsDigest = blocks.docsDigest;
@@ -61660,6 +61708,7 @@ function getFeatureFull(ctx, id3) {
61660
61708
  sessions: listSessionsByFeature(ctx, id3).map((session) => session.kind === "chat" && session.status === "ended" && !session.ccSessionId ? { ...session, title: null, transcriptMissing: true } : session),
61661
61709
  runs: listRunsByFeature(ctx, id3),
61662
61710
  docs: listDocs(ctx, feature),
61711
+ preview: previewSummary(ctx, feature),
61663
61712
  dependsOn: dependsOnOf(ctx, id3),
61664
61713
  blocks: blocksOf(ctx, id3)
61665
61714
  };
@@ -64725,7 +64774,7 @@ function ptyExitMessage(exitCode) {
64725
64774
  const label = typeof exitCode === "number" ? String(exitCode) : "unknown";
64726
64775
  return `terminal exited (code ${label})`;
64727
64776
  }
64728
- function chatKickoffHeader(ctx, feature, runtime5 = DEFAULT_RUNTIME) {
64777
+ function chatKickoffHeader(ctx, feature, runtime5 = DEFAULT_RUNTIME, opening = chatKickoffFor(runtime5, chatOpening(feature, planningFacts(ctx, feature)))) {
64729
64778
  const counts = new Map;
64730
64779
  for (const ticket of listByFeature(ctx, feature.id)) {
64731
64780
  counts.set(ticket.status, (counts.get(ticket.status) ?? 0) + 1);
@@ -64738,7 +64787,6 @@ function chatKickoffHeader(ctx, feature, runtime5 = DEFAULT_RUNTIME) {
64738
64787
  const outcome = !latestRun ? "never driven" : latestReview?.status === "done" ? "passed" : latestReview?.status === "failed" ? "failed" : "unverified";
64739
64788
  review = ` Drive outcome: ${outcome}; see the review evidence in get_feature_context.`;
64740
64789
  }
64741
- const opening = chatKickoffFor(runtime5, chatOpening(feature, planningFacts(ctx, feature)));
64742
64790
  return `${opening} Feature state: ${feature.phase}; lap ${feature.lap}; tickets: ${ticketSummary}; latest run: ${latestRun?.status ?? "none"}.${review} Call get_feature_context for the full picture.`;
64743
64791
  }
64744
64792
  function assertRuntimeReady(runtime5, opts) {
@@ -64765,6 +64813,21 @@ function assertSpawnable(ctx, feature, excludeSessionId) {
64765
64813
  throw new GateError(`a ${live[0].kind} session is already live for ${feature.slug} \u2014 only one terminal per feature; end or resume it first`);
64766
64814
  }
64767
64815
  }
64816
+ function isSelfPlan(input) {
64817
+ return input.kind === "chat" && input.purpose === "self-plan";
64818
+ }
64819
+ function assertSelfPlannable(ctx, feature) {
64820
+ if (feature.phase !== "planning") {
64821
+ throw new GateError(`${feature.slug} is past planning, so there is nothing for it to plan by itself \u2014 open the chat to change it`);
64822
+ }
64823
+ if (chatOpening(feature, planningFacts(ctx, feature)) !== "ideate") {
64824
+ throw new GateError(`${feature.slug} already has planning on record (decisions, a spec or tickets), so it cannot plan itself from scratch \u2014 open the chat to carry on`);
64825
+ }
64826
+ assertSpawnable(ctx, feature);
64827
+ }
64828
+ function selfPlanRunningError(feature) {
64829
+ return new GateError(`${feature.slug} is planning itself \u2014 stop the run first, then open the chat to pick up where it got to`);
64830
+ }
64768
64831
  async function ensureWorktree(ctx, project3, feature) {
64769
64832
  const running3 = activeRunFor(ctx, feature.id);
64770
64833
  const besideRun = !!running3 && workflowClaimsFeatureBranch(running3.workflow);
@@ -64846,7 +64909,11 @@ async function launchSession(ctx, input, opts = {}) {
64846
64909
  requireNotDraft(feature);
64847
64910
  if (input.kind === "chat") {
64848
64911
  const liveChat = activeSessionsForFeature(ctx, feature.id).find((s) => s.kind === "chat");
64849
- if (liveChat && isStartLap(feature, input))
64912
+ if (liveChat?.purpose === "self-plan")
64913
+ throw selfPlanRunningError(feature);
64914
+ if (isSelfPlan(input))
64915
+ assertSelfPlannable(ctx, feature);
64916
+ else if (liveChat && isStartLap(feature, input))
64850
64917
  endSession(ctx, liveChat.id);
64851
64918
  else if (liveChat)
64852
64919
  return briefLiveChat(ctx, feature, liveChat, input);
@@ -64866,8 +64933,9 @@ async function launchSession(ctx, input, opts = {}) {
64866
64933
  });
64867
64934
  const carried = startLapCarriedWork(ctx, feature.id);
64868
64935
  const plan = planLaunchKickoff(feature, input, carried);
64936
+ const selfPlan = isSelfPlan(input);
64869
64937
  if (input.kind === "chat") {
64870
- const header = chatKickoffHeader(ctx, feature, runtime5.id);
64938
+ const header = chatKickoffHeader(ctx, feature, runtime5.id, selfPlan ? selfPlanKickoffFor(runtime5.id) : undefined);
64871
64939
  plan.line = plan.line ? `${plan.line} ${header}` : header;
64872
64940
  }
64873
64941
  let resumeSessionId;
@@ -64880,6 +64948,8 @@ async function launchSession(ctx, input, opts = {}) {
64880
64948
  markSessionEnded(ctx, session.id);
64881
64949
  throw e;
64882
64950
  }
64951
+ }
64952
+ if (input.kind === "chat" && !selfPlan) {
64883
64953
  const prior = mostRecentResumableSession(ctx, feature.id, "chat");
64884
64954
  if (prior?.ccSessionId) {
64885
64955
  resumedFrom = prior;
@@ -64919,12 +64989,13 @@ async function launchSession(ctx, input, opts = {}) {
64919
64989
  const kickoffLine = resumeSessionId && input.kind !== "chat" ? undefined : kickoffLineFor(input.kind, plan.line, runtime5.id);
64920
64990
  emit(ctx, feature.id, {
64921
64991
  type: "session.launching",
64922
- message: `launching ${input.kind} session`,
64992
+ message: selfPlan ? "launching a self-planning run" : `launching ${input.kind} session`,
64923
64993
  data: {
64924
64994
  sessionId: session.id,
64925
64995
  kind: input.kind,
64926
64996
  worktreePath,
64927
- ...modelStamp(model)
64997
+ ...modelStamp(model),
64998
+ ...input.purpose ? { purpose: input.purpose } : {}
64928
64999
  }
64929
65000
  });
64930
65001
  if (resumeSessionId) {
@@ -64961,7 +65032,8 @@ async function launchSession(ctx, input, opts = {}) {
64961
65032
  model: model.id,
64962
65033
  resumeSessionId,
64963
65034
  resumeSourceSessionId: resumedFrom?.id,
64964
- kickoffLine
65035
+ kickoffLine,
65036
+ ...selfPlan ? { headless: true } : {}
64965
65037
  });
64966
65038
  if (opts.spawn === false) {
64967
65039
  emit(ctx, feature.id, {
@@ -65307,6 +65379,15 @@ function handlePtyExit(ctx, feature, session, meta, exitCode) {
65307
65379
  }
65308
65380
  });
65309
65381
  }
65382
+ if (session.purpose === "self-plan" && feature) {
65383
+ const current2 = getFeatureRow(ctx, feature.id);
65384
+ const complete4 = current2.ticketsReadyLap === current2.lap;
65385
+ emitForSession(ctx, session, {
65386
+ type: "session.self_plan_ended",
65387
+ message: complete4 ? "planning finished" : "planning stopped before the plan was complete",
65388
+ data: { sessionId: session.id, complete: complete4, exitCode: exitCode ?? null }
65389
+ });
65390
+ }
65310
65391
  emitForSession(ctx, session, {
65311
65392
  type: "session.pty_exited",
65312
65393
  message: ptyExitMessage(exitCode),