@0xtiby/toby 1.2.0 → 1.3.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.
Files changed (2) hide show
  1. package/dist/cli.js +355 -134
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -65,11 +65,18 @@ var SpecStatusEntrySchema = z.object({
65
65
  iterations: z.array(IterationSchema),
66
66
  stopReason: StopReasonSchema.optional()
67
67
  });
68
+ var SessionStateSchema = z.enum(["active", "interrupted"]);
69
+ var SessionSchema = z.object({
70
+ name: z.string(),
71
+ cli: z.string(),
72
+ specs: z.array(z.string()),
73
+ state: SessionStateSchema,
74
+ startedAt: z.string().datetime()
75
+ });
68
76
  var StatusSchema = z.object({
69
77
  specs: z.record(z.string(), SpecStatusEntrySchema),
70
- sessionName: z.string().optional(),
71
- lastCli: z.string().optional()
72
- });
78
+ session: SessionSchema.optional()
79
+ }).strip();
73
80
 
74
81
  // src/lib/paths.ts
75
82
  import path from "path";
@@ -162,6 +169,29 @@ var commandHelp = {
162
169
  }
163
170
  ]
164
171
  },
172
+ resume: {
173
+ summary: "Resume an interrupted build session",
174
+ usage: ["$ toby resume [options]"],
175
+ flags: [
176
+ { name: "--iterations=<n>", description: "Override iteration count" },
177
+ { name: "--verbose", description: "Show full CLI output" },
178
+ { name: "--transcript", description: "Save session transcript to file" }
179
+ ],
180
+ examples: [
181
+ {
182
+ command: "toby resume",
183
+ description: "Resume the most recent interrupted session from where it left off"
184
+ },
185
+ {
186
+ command: "toby resume --iterations=10 --verbose",
187
+ description: "Resume with 10 iterations per spec and full CLI output"
188
+ },
189
+ {
190
+ command: "toby resume --transcript",
191
+ description: "Resume and save a transcript of the resumed session"
192
+ }
193
+ ]
194
+ },
165
195
  init: {
166
196
  summary: "Initialize toby in current project",
167
197
  usage: ["$ toby init [options]"],
@@ -759,6 +789,28 @@ function addIteration(status, specName, iteration) {
759
789
  }
760
790
  };
761
791
  }
792
+ function createSession(name, cli2, specs) {
793
+ return {
794
+ name,
795
+ cli: cli2,
796
+ specs,
797
+ state: "active",
798
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
799
+ };
800
+ }
801
+ function updateSessionState(status, state) {
802
+ if (!status.session) return status;
803
+ return {
804
+ ...status,
805
+ session: { ...status.session, state }
806
+ };
807
+ }
808
+ function clearSession(status) {
809
+ return { specs: status.specs };
810
+ }
811
+ function hasResumableSession(status) {
812
+ return status.session?.state === "active" || status.session?.state === "interrupted";
813
+ }
762
814
  function updateSpecStatus(status, specName, newStatus) {
763
815
  const entry = getSpecStatus(status, specName);
764
816
  return {
@@ -959,16 +1011,19 @@ function useCommandRunner(options) {
959
1011
  if (filtered.length === 0) {
960
1012
  setErrorMessage(emptyMessage ?? "No specs found.");
961
1013
  setPhase("error");
962
- exit();
963
1014
  return;
964
1015
  }
965
1016
  setSpecs(filtered);
966
1017
  } catch (err) {
967
1018
  setErrorMessage(err.message);
968
1019
  setPhase("error");
969
- exit(new Error(err.message));
970
1020
  }
971
1021
  }, [phase]);
1022
+ useEffect(() => {
1023
+ if (phase === "error" && errorMessage) {
1024
+ exit(new Error(errorMessage));
1025
+ }
1026
+ }, [phase, errorMessage]);
972
1027
  useEffect(() => {
973
1028
  if (phase !== "multi" || selectedSpecs.length > 0) return;
974
1029
  if (!flags2.spec) return;
@@ -1400,14 +1455,10 @@ function Plan(flags2) {
1400
1455
  import { useState as useState4, useEffect as useEffect3, useMemo as useMemo3 } from "react";
1401
1456
  import { Text as Text4, Box as Box4 } from "ink";
1402
1457
  import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
1403
- function detectResume(specEntry, currentCli, lastCli) {
1458
+ function resolveResumeSessionId(specEntry, currentCli, sessionCli) {
1459
+ if (currentCli !== sessionCli) return void 0;
1404
1460
  const lastIteration = specEntry?.iterations.at(-1);
1405
- const isCrashResume = !!(specEntry?.status !== "done" && lastIteration?.state === "in_progress");
1406
- const isExhaustedResume = !!(specEntry?.status !== "done" && specEntry?.stopReason === "max_iterations");
1407
- const needsResume = isCrashResume || isExhaustedResume;
1408
- const isSameCli = currentCli === lastCli;
1409
- const sessionId = isSameCli && isCrashResume ? lastIteration?.sessionId ?? void 0 : void 0;
1410
- return { isCrashResume, isExhaustedResume, needsResume, sessionId };
1461
+ return lastIteration?.sessionId ?? void 0;
1411
1462
  }
1412
1463
  async function runSpecBuild(options) {
1413
1464
  const { spec, iterations, cli: cli2, model, cwd, callbacks } = options;
@@ -1456,7 +1507,6 @@ async function runSpecBuild(options) {
1456
1507
  tokensUsed: null
1457
1508
  };
1458
1509
  status = addIteration(status, spec.name, iterationRecord);
1459
- status = { ...status, sessionName: options.session, lastCli: cli2 };
1460
1510
  writeStatus(status, cwd);
1461
1511
  },
1462
1512
  onIterationComplete: (iterResult) => {
@@ -1534,20 +1584,22 @@ async function executeBuild(flags2, callbacks = {}, cwd = process.cwd(), abortSi
1534
1584
  if (!found) {
1535
1585
  throw new Error(`Spec '${flags2.spec}' not found`);
1536
1586
  }
1537
- const status = readStatus(cwd);
1587
+ let status = readStatus(cwd);
1538
1588
  const specEntry = status.specs[found.name];
1589
+ if (specEntry?.status === "done") {
1590
+ throw new Error(`Spec '${found.name}' is already done. Reset its status in .toby/status.json to rebuild.`);
1591
+ }
1539
1592
  if (!specEntry || specEntry.status !== "planned" && specEntry.status !== "building") {
1540
1593
  throw new Error(`No plan found for ${found.name}. Run 'toby plan --spec=${flags2.spec}' first.`);
1541
1594
  }
1542
1595
  const existingIterations = specEntry.iterations.length;
1543
- const resume = detectResume(specEntry, commandConfig.cli, status.lastCli);
1544
- const session = flags2.session || (resume.needsResume ? status.sessionName : null) || computeSpecSlug(found.name);
1545
- if (resume.isCrashResume) {
1546
- const isSameCli = commandConfig.cli === status.lastCli;
1547
- const resumeType = isSameCli ? "continuing session" : `switching from ${status.lastCli} to ${commandConfig.cli}`;
1548
- callbacks.onOutput?.(`Resuming session "${session}" (${resumeType})`);
1549
- } else if (resume.isExhaustedResume) {
1550
- callbacks.onOutput?.(`\u26A0 Previous build exhausted iterations without completing. Resuming in worktree "${session}"...`);
1596
+ const session = flags2.session || status.session?.name || computeSpecSlug(found.name);
1597
+ const sessionCli = status.session?.cli ?? commandConfig.cli;
1598
+ const resumeSessionId = resolveResumeSessionId(specEntry, commandConfig.cli, sessionCli);
1599
+ if (!status.session) {
1600
+ const sessionObj = createSession(session, commandConfig.cli, [found.name]);
1601
+ status = { ...status, session: sessionObj };
1602
+ writeStatus(status, cwd);
1551
1603
  }
1552
1604
  return withTranscript(
1553
1605
  { flags: flags2, config, command: "build", specName: found.name },
@@ -1563,7 +1615,7 @@ async function executeBuild(flags2, callbacks = {}, cwd = process.cwd(), abortSi
1563
1615
  templateVars: config.templateVars,
1564
1616
  specsDir: config.specsDir,
1565
1617
  session,
1566
- sessionId: resume.sessionId,
1618
+ sessionId: resumeSessionId,
1567
1619
  specIndex: 1,
1568
1620
  specCount: 1,
1569
1621
  specs: [found.name],
@@ -1572,7 +1624,14 @@ async function executeBuild(flags2, callbacks = {}, cwd = process.cwd(), abortSi
1572
1624
  callbacks,
1573
1625
  writer
1574
1626
  });
1575
- return { ...result, needsResume: resume.needsResume };
1627
+ let finalStatus = readStatus(cwd);
1628
+ if (result.specDone) {
1629
+ finalStatus = clearSession(finalStatus);
1630
+ } else {
1631
+ finalStatus = updateSessionState(finalStatus, "interrupted");
1632
+ }
1633
+ writeStatus(finalStatus, cwd);
1634
+ return result;
1576
1635
  }
1577
1636
  );
1578
1637
  }
@@ -1594,62 +1653,99 @@ async function executeBuildAll(flags2, callbacks = {}, cwd = process.cwd(), abor
1594
1653
  }
1595
1654
  const built = [];
1596
1655
  const specNames = planned.map((s) => s.name);
1597
- const status = readStatus(cwd);
1656
+ let status = readStatus(cwd);
1657
+ const buildable = planned.filter((spec) => {
1658
+ const entry = status.specs[spec.name];
1659
+ return entry?.status !== "done";
1660
+ });
1598
1661
  const commandConfig = resolveCommandConfig(config, "build", {
1599
1662
  cli: flags2.cli,
1600
1663
  iterations: flags2.iterations
1601
1664
  });
1602
- const anyNeedsResume = planned.some((spec) => {
1603
- return detectResume(status.specs[spec.name], commandConfig.cli, status.lastCli).needsResume;
1604
- });
1605
- const session = flags2.session || (anyNeedsResume ? status.sessionName : null) || generateSessionName();
1665
+ const session = flags2.session || status.session?.name || generateSessionName();
1666
+ const sessionCli = status.session?.cli ?? commandConfig.cli;
1667
+ const existingSession = status.session;
1668
+ if (!existingSession) {
1669
+ const sessionObj2 = createSession(session, commandConfig.cli, specNames);
1670
+ status = { ...status, session: sessionObj2 };
1671
+ writeStatus(status, cwd);
1672
+ } else {
1673
+ status = updateSessionState(status, "active");
1674
+ writeStatus(status, cwd);
1675
+ }
1676
+ const sessionObj = status.session;
1606
1677
  return withTranscript(
1607
1678
  { flags: { ...flags2, session: flags2.session ?? session }, config, command: "build" },
1608
1679
  void 0,
1609
1680
  async (writer) => {
1610
- for (let i = 0; i < planned.length; i++) {
1611
- const spec = planned[i];
1612
- writer?.writeSpecHeader(i + 1, planned.length, spec.name);
1613
- callbacks.onSpecStart?.(spec.name, i, planned.length);
1614
- const specEntry = status.specs[spec.name];
1615
- const existingIterations = specEntry?.iterations.length ?? 0;
1616
- const resume = detectResume(specEntry, commandConfig.cli, status.lastCli);
1617
- if (resume.isCrashResume) {
1618
- const lastIteration = specEntry?.iterations.at(-1);
1619
- callbacks.onOutput?.(
1620
- `\u26A0 [${spec.name}] Previous build interrupted (iteration ${lastIteration?.iteration} was in progress). Resuming...`
1621
- );
1622
- } else if (resume.isExhaustedResume) {
1623
- callbacks.onOutput?.(
1624
- `\u26A0 [${spec.name}] Previous build exhausted iterations without completing. Resuming in same worktree...`
1625
- );
1681
+ try {
1682
+ for (let i = 0; i < buildable.length; i++) {
1683
+ const spec = buildable[i];
1684
+ const specIndex = planned.indexOf(spec) + 1;
1685
+ writer?.writeSpecHeader(specIndex, planned.length, spec.name);
1686
+ callbacks.onSpecStart?.(spec.name, specIndex - 1, planned.length);
1687
+ const specEntry = status.specs[spec.name];
1688
+ const existingIterations = specEntry?.iterations.length ?? 0;
1689
+ const resumeSessionId = resolveResumeSessionId(specEntry, commandConfig.cli, sessionCli);
1690
+ const { result } = await runSpecBuild({
1691
+ spec,
1692
+ promptName: "PROMPT_BUILD",
1693
+ existingIterations,
1694
+ iterations: commandConfig.iterations,
1695
+ cli: commandConfig.cli,
1696
+ model: commandConfig.model,
1697
+ templateVars: config.templateVars,
1698
+ specsDir: config.specsDir,
1699
+ session,
1700
+ sessionId: resumeSessionId,
1701
+ specIndex,
1702
+ specCount: planned.length,
1703
+ specs: specNames,
1704
+ cwd,
1705
+ abortSignal,
1706
+ callbacks: {
1707
+ onPhase: callbacks.onPhase,
1708
+ onIteration: callbacks.onIteration,
1709
+ onEvent: callbacks.onEvent,
1710
+ onOutput: callbacks.onOutput
1711
+ },
1712
+ writer
1713
+ });
1714
+ built.push(result);
1715
+ callbacks.onSpecComplete?.(result);
1716
+ if (!result.specDone) {
1717
+ let currentStatus = readStatus(cwd);
1718
+ currentStatus = updateSessionState(currentStatus, "interrupted");
1719
+ writeStatus(currentStatus, cwd);
1720
+ const allSpecNames = sessionObj.specs;
1721
+ const doneSpecs = allSpecNames.filter((name) => {
1722
+ return currentStatus.specs[name]?.status === "done";
1723
+ });
1724
+ const remainingSpecs = allSpecNames.filter((name) => !doneSpecs.includes(name));
1725
+ callbacks.onOutput?.(
1726
+ `Session "${sessionObj.name}" interrupted at ${spec.name} (${result.error ? "error" : "incomplete"}).`
1727
+ );
1728
+ callbacks.onOutput?.(
1729
+ `Completed: ${doneSpecs.join(", ") || "none"} (${doneSpecs.length}/${allSpecNames.length})`
1730
+ );
1731
+ callbacks.onOutput?.(
1732
+ `Remaining: ${remainingSpecs.join(", ")} (${remainingSpecs.length}/${allSpecNames.length})`
1733
+ );
1734
+ callbacks.onOutput?.("Run 'toby resume' to continue.");
1735
+ break;
1736
+ }
1626
1737
  }
1627
- const { result } = await runSpecBuild({
1628
- spec,
1629
- promptName: "PROMPT_BUILD",
1630
- existingIterations,
1631
- iterations: commandConfig.iterations,
1632
- cli: commandConfig.cli,
1633
- model: commandConfig.model,
1634
- templateVars: config.templateVars,
1635
- specsDir: config.specsDir,
1636
- session,
1637
- sessionId: resume.sessionId,
1638
- specIndex: i + 1,
1639
- specCount: planned.length,
1640
- specs: specNames,
1641
- cwd,
1642
- abortSignal,
1643
- callbacks: {
1644
- onPhase: callbacks.onPhase,
1645
- onIteration: callbacks.onIteration,
1646
- onEvent: callbacks.onEvent,
1647
- onOutput: callbacks.onOutput
1648
- },
1649
- writer
1650
- });
1651
- built.push({ ...result, needsResume: resume.needsResume });
1652
- callbacks.onSpecComplete?.({ ...result, needsResume: resume.needsResume });
1738
+ } catch (err) {
1739
+ if (err instanceof AbortError) {
1740
+ const currentStatus = readStatus(cwd);
1741
+ writeStatus(updateSessionState(currentStatus, "interrupted"), cwd);
1742
+ }
1743
+ throw err;
1744
+ }
1745
+ const finalStatus = readStatus(cwd);
1746
+ const allDone = sessionObj.specs.every((name) => finalStatus.specs[name]?.status === "done");
1747
+ if (allDone) {
1748
+ writeStatus(clearSession(finalStatus), cwd);
1653
1749
  }
1654
1750
  return { built };
1655
1751
  }
@@ -2916,13 +3012,123 @@ function Clean({ force }) {
2916
3012
  return null;
2917
3013
  }
2918
3014
 
3015
+ // src/commands/resume.tsx
3016
+ import { useState as useState9, useEffect as useEffect8, useRef as useRef2 } from "react";
3017
+ import { Box as Box9, Text as Text10, useApp as useApp5 } from "ink";
3018
+ import { jsx as jsx10, jsxs as jsxs9 } from "react/jsx-runtime";
3019
+ async function executeResume(flags2, callbacks = {}, cwd = process.cwd(), abortSignal) {
3020
+ const status = readStatus(cwd);
3021
+ if (!hasResumableSession(status)) {
3022
+ throw new Error(
3023
+ "No active session to resume. Use 'toby build --spec=<name>' to start a new build."
3024
+ );
3025
+ }
3026
+ const session = status.session;
3027
+ const config = loadConfig(cwd);
3028
+ const commandConfig = resolveCommandConfig(config, "build", {
3029
+ iterations: flags2.iterations
3030
+ });
3031
+ const allSpecs = discoverSpecs(cwd, config);
3032
+ const incompleteNames = [];
3033
+ const missingNames = [];
3034
+ for (const specName of session.specs) {
3035
+ const entry = status.specs[specName];
3036
+ if (entry?.status === "done") {
3037
+ callbacks.onOutput?.(` \u2713 ${specName} (done, skipping)`);
3038
+ continue;
3039
+ }
3040
+ const found = findSpec(allSpecs, specName);
3041
+ if (!found) {
3042
+ missingNames.push(specName);
3043
+ callbacks.onOutput?.(` \u26A0 ${specName} (not found in specs/, skipping)`);
3044
+ continue;
3045
+ }
3046
+ incompleteNames.push(specName);
3047
+ }
3048
+ if (missingNames.length === session.specs.length) {
3049
+ throw new Error(
3050
+ "All session specs are missing from specs/ directory. Cannot resume."
3051
+ );
3052
+ }
3053
+ if (incompleteNames.length === 0) {
3054
+ throw new Error(
3055
+ missingNames.length > 0 ? "All remaining session specs are missing from specs/. Nothing to resume." : "All specs in this session are already done. Nothing to resume."
3056
+ );
3057
+ }
3058
+ const specsToResume = incompleteNames.map((name) => findSpec(allSpecs, name));
3059
+ callbacks.onOutput?.(`Resuming session "${session.name}" with ${specsToResume.length} spec(s):`);
3060
+ for (const spec of specsToResume) {
3061
+ callbacks.onOutput?.(` \u2192 ${spec.name}`);
3062
+ }
3063
+ const updatedStatus = updateSessionState(status, "active");
3064
+ writeStatus(updatedStatus, cwd);
3065
+ const buildFlags = {
3066
+ spec: void 0,
3067
+ all: true,
3068
+ iterations: flags2.iterations ?? commandConfig.iterations,
3069
+ verbose: flags2.verbose ?? false,
3070
+ transcript: flags2.transcript,
3071
+ cli: commandConfig.cli,
3072
+ session: session.name
3073
+ };
3074
+ return executeBuildAll(buildFlags, callbacks, cwd, abortSignal, specsToResume);
3075
+ }
3076
+ function Resume(props) {
3077
+ const { exit } = useApp5();
3078
+ const [phase, setPhase] = useState9("loading");
3079
+ const [messages, setMessages] = useState9([]);
3080
+ const [result, setResult] = useState9(null);
3081
+ const [errorMessage, setErrorMessage] = useState9("");
3082
+ const abortController = useRef2(new AbortController());
3083
+ useEffect8(() => {
3084
+ const callbacks = {
3085
+ onOutput: (msg) => setMessages((prev) => [...prev, msg])
3086
+ };
3087
+ setPhase("building");
3088
+ executeResume(props, callbacks, void 0, abortController.current.signal).then((r) => {
3089
+ setResult(r);
3090
+ setPhase("done");
3091
+ }).catch((err) => {
3092
+ if (abortController.current.signal.aborted) return;
3093
+ setErrorMessage(err.message);
3094
+ setPhase("error");
3095
+ });
3096
+ return () => {
3097
+ abortController.current.abort();
3098
+ };
3099
+ }, []);
3100
+ useEffect8(() => {
3101
+ if (phase === "done" || phase === "error") {
3102
+ const timer = setTimeout(() => exit(), 100);
3103
+ return () => clearTimeout(timer);
3104
+ }
3105
+ }, [phase, exit]);
3106
+ if (phase === "error") {
3107
+ return /* @__PURE__ */ jsx10(Text10, { color: "red", children: errorMessage });
3108
+ }
3109
+ if (phase === "done" && result) {
3110
+ const totalIter = result.built.reduce((s, r) => s + r.totalIterations, 0);
3111
+ const totalTok = result.built.reduce((s, r) => s + r.totalTokens, 0);
3112
+ return /* @__PURE__ */ jsxs9(Box9, { flexDirection: "column", children: [
3113
+ messages.map((msg, i) => /* @__PURE__ */ jsx10(Text10, { dimColor: true, children: msg }, i)),
3114
+ /* @__PURE__ */ jsx10(Text10, { color: "green", children: `\u2713 Resume complete (${result.built.length} spec(s) built)` }),
3115
+ result.built.map((r) => /* @__PURE__ */ jsx10(Text10, { children: ` ${r.specName}: ${r.totalIterations} iterations, ${r.totalTokens} tokens${r.specDone ? " [done]" : ""}` }, r.specName)),
3116
+ /* @__PURE__ */ jsx10(Text10, { dimColor: true, children: ` Total: ${totalIter} iterations, ${totalTok} tokens` })
3117
+ ] });
3118
+ }
3119
+ return /* @__PURE__ */ jsxs9(Box9, { flexDirection: "column", children: [
3120
+ messages.map((msg, i) => /* @__PURE__ */ jsx10(Text10, { dimColor: true, children: msg }, i)),
3121
+ /* @__PURE__ */ jsx10(Text10, { dimColor: true, children: "Resuming build..." })
3122
+ ] });
3123
+ }
3124
+
2919
3125
  // src/components/Welcome.tsx
2920
- import { useState as useState10, useEffect as useEffect9, useMemo as useMemo6 } from "react";
2921
- import { Box as Box12, Text as Text13, useApp as useApp5, useStdout as useStdout2 } from "ink";
3126
+ import { useState as useState11, useEffect as useEffect10, useMemo as useMemo6 } from "react";
3127
+ import { Box as Box13, Text as Text14, useApp as useApp6, useStdout as useStdout2 } from "ink";
2922
3128
 
2923
3129
  // src/components/hamster/HamsterWheel.tsx
2924
- import { useState as useState9, useEffect as useEffect8, useMemo as useMemo5 } from "react";
2925
- import { Box as Box9, Text as Text10, useStdout } from "ink";
3130
+ import { useState as useState10, useEffect as useEffect9, useMemo as useMemo5 } from "react";
3131
+ import { Box as Box10, Text as Text11, useStdout } from "ink";
2926
3132
 
2927
3133
  // src/components/hamster/palette.ts
2928
3134
  var PALETTE = {
@@ -3081,7 +3287,7 @@ function generateWheelPixels(cx, cy, outerRadius, innerRadius, spokeAngle, aspec
3081
3287
  }
3082
3288
 
3083
3289
  // src/components/hamster/HamsterWheel.tsx
3084
- import { jsx as jsx10 } from "react/jsx-runtime";
3290
+ import { jsx as jsx11 } from "react/jsx-runtime";
3085
3291
  function buildGrid(width, height) {
3086
3292
  return Array.from(
3087
3293
  { length: height },
@@ -3164,9 +3370,9 @@ function HamsterWheel({
3164
3370
  heightProp,
3165
3371
  columns
3166
3372
  );
3167
- const [frame, setFrame] = useState9(0);
3168
- const [spokeAngle, setSpokeAngle] = useState9(0);
3169
- useEffect8(() => {
3373
+ const [frame, setFrame] = useState10(0);
3374
+ const [spokeAngle, setSpokeAngle] = useState10(0);
3375
+ useEffect9(() => {
3170
3376
  if (speed === 0 || isStatic) return;
3171
3377
  const interval = computeInterval(HAMSTER_BASE_INTERVAL, speed);
3172
3378
  const id = setInterval(() => {
@@ -3174,7 +3380,7 @@ function HamsterWheel({
3174
3380
  }, interval);
3175
3381
  return () => clearInterval(id);
3176
3382
  }, [speed, isStatic]);
3177
- useEffect8(() => {
3383
+ useEffect9(() => {
3178
3384
  if (speed === 0 || isStatic) return;
3179
3385
  const interval = computeInterval(WHEEL_BASE_INTERVAL, speed);
3180
3386
  const id = setInterval(() => {
@@ -3214,60 +3420,61 @@ function HamsterWheel({
3214
3420
  return buildColorRuns(grid, resolvedWidth, resolvedHeight);
3215
3421
  }, [resolvedWidth, resolvedHeight, frame, spokeAngle, isStatic]);
3216
3422
  if (isStatic) {
3217
- return /* @__PURE__ */ jsx10(Text10, { children: " \u{1F439} toby" });
3423
+ return /* @__PURE__ */ jsx11(Text11, { children: " \u{1F439} toby" });
3218
3424
  }
3219
- return /* @__PURE__ */ jsx10(Box9, { flexDirection: "column", children: renderedRows.map((runs, y) => /* @__PURE__ */ jsx10(Text10, { children: runs.map((run, i) => /* @__PURE__ */ jsx10(Text10, { color: run.fg, backgroundColor: run.bg, children: run.char.repeat(run.length) }, i)) }, y)) });
3425
+ return /* @__PURE__ */ jsx11(Box10, { flexDirection: "column", children: renderedRows.map((runs, y) => /* @__PURE__ */ jsx11(Text11, { children: runs.map((run, i) => /* @__PURE__ */ jsx11(Text11, { color: run.fg, backgroundColor: run.bg, children: run.char.repeat(run.length) }, i)) }, y)) });
3220
3426
  }
3221
3427
 
3222
3428
  // src/components/InfoPanel.tsx
3223
- import { Box as Box10, Text as Text11 } from "ink";
3224
- import { jsx as jsx11, jsxs as jsxs9 } from "react/jsx-runtime";
3429
+ import { Box as Box11, Text as Text12 } from "ink";
3430
+ import { jsx as jsx12, jsxs as jsxs10 } from "react/jsx-runtime";
3225
3431
  var formatTokens = (n) => new Intl.NumberFormat().format(n);
3226
3432
  function StatRow({ label, value }) {
3227
- return /* @__PURE__ */ jsxs9(Box10, { children: [
3228
- /* @__PURE__ */ jsxs9(Text11, { dimColor: true, children: [
3433
+ return /* @__PURE__ */ jsxs10(Box11, { children: [
3434
+ /* @__PURE__ */ jsxs10(Text12, { dimColor: true, children: [
3229
3435
  String(label).padStart(9),
3230
3436
  " "
3231
3437
  ] }),
3232
- /* @__PURE__ */ jsx11(Text11, { children: value })
3438
+ /* @__PURE__ */ jsx12(Text12, { children: value })
3233
3439
  ] });
3234
3440
  }
3235
3441
  function InfoPanel({ version: version2, stats }) {
3236
- return /* @__PURE__ */ jsxs9(Box10, { flexDirection: "column", children: [
3237
- /* @__PURE__ */ jsxs9(Text11, { bold: true, color: "#f0a030", children: [
3442
+ return /* @__PURE__ */ jsxs10(Box11, { flexDirection: "column", children: [
3443
+ /* @__PURE__ */ jsxs10(Text12, { bold: true, color: "#f0a030", children: [
3238
3444
  "toby v",
3239
3445
  version2
3240
3446
  ] }),
3241
- stats !== null && /* @__PURE__ */ jsxs9(Box10, { flexDirection: "column", marginTop: 1, children: [
3242
- /* @__PURE__ */ jsx11(StatRow, { label: "Specs", value: stats.totalSpecs }),
3243
- /* @__PURE__ */ jsx11(StatRow, { label: "Planned", value: stats.planned }),
3244
- /* @__PURE__ */ jsx11(StatRow, { label: "Done", value: stats.done }),
3245
- /* @__PURE__ */ jsx11(StatRow, { label: "Tokens", value: formatTokens(stats.totalTokens) })
3447
+ stats !== null && /* @__PURE__ */ jsxs10(Box11, { flexDirection: "column", marginTop: 1, children: [
3448
+ /* @__PURE__ */ jsx12(StatRow, { label: "Specs", value: stats.totalSpecs }),
3449
+ /* @__PURE__ */ jsx12(StatRow, { label: "Planned", value: stats.planned }),
3450
+ /* @__PURE__ */ jsx12(StatRow, { label: "Done", value: stats.done }),
3451
+ /* @__PURE__ */ jsx12(StatRow, { label: "Tokens", value: formatTokens(stats.totalTokens) })
3246
3452
  ] })
3247
3453
  ] });
3248
3454
  }
3249
3455
 
3250
3456
  // src/components/MainMenu.tsx
3251
- import { Text as Text12, Box as Box11 } from "ink";
3457
+ import { Text as Text13, Box as Box12 } from "ink";
3252
3458
  import SelectInput3 from "ink-select-input";
3253
- import { jsx as jsx12, jsxs as jsxs10 } from "react/jsx-runtime";
3459
+ import { jsx as jsx13, jsxs as jsxs11 } from "react/jsx-runtime";
3254
3460
  var MENU_ITEMS = [
3255
3461
  { label: "plan", value: "plan", description: "Plan specs with AI loop engine" },
3256
3462
  { label: "build", value: "build", description: "Build tasks one-per-spawn with AI" },
3463
+ { label: "resume", value: "resume", description: "Resume an interrupted build session" },
3257
3464
  { label: "status", value: "status", description: "Show project status" },
3258
3465
  { label: "config", value: "config", description: "Manage configuration" }
3259
3466
  ];
3260
3467
  function MenuItem({ isSelected = false, label, description }) {
3261
- return /* @__PURE__ */ jsxs10(Box11, { children: [
3262
- /* @__PURE__ */ jsx12(Text12, { color: isSelected ? "blue" : void 0, children: label.padEnd(10) }),
3263
- description && /* @__PURE__ */ jsxs10(Text12, { dimColor: true, children: [
3468
+ return /* @__PURE__ */ jsxs11(Box12, { children: [
3469
+ /* @__PURE__ */ jsx13(Text13, { color: isSelected ? "blue" : void 0, children: label.padEnd(10) }),
3470
+ description && /* @__PURE__ */ jsxs11(Text13, { dimColor: true, children: [
3264
3471
  "\u2014 ",
3265
3472
  description
3266
3473
  ] })
3267
3474
  ] });
3268
3475
  }
3269
3476
  function MainMenu({ onSelect }) {
3270
- return /* @__PURE__ */ jsx12(Box11, { flexDirection: "column", children: /* @__PURE__ */ jsx12(
3477
+ return /* @__PURE__ */ jsx13(Box12, { flexDirection: "column", children: /* @__PURE__ */ jsx13(
3271
3478
  SelectInput3,
3272
3479
  {
3273
3480
  items: MENU_ITEMS,
@@ -3322,53 +3529,56 @@ function computeProjectStats(cwd) {
3322
3529
  }
3323
3530
 
3324
3531
  // src/components/Welcome.tsx
3325
- import { jsx as jsx13, jsxs as jsxs11 } from "react/jsx-runtime";
3532
+ import { jsx as jsx14, jsxs as jsxs12 } from "react/jsx-runtime";
3326
3533
  var NARROW_THRESHOLD = 60;
3327
3534
  function Welcome({ version: version2 }) {
3328
- const { exit } = useApp5();
3535
+ const { exit } = useApp6();
3329
3536
  const { stdout } = useStdout2();
3330
- const [selectedCommand, setSelectedCommand] = useState10(null);
3537
+ const [selectedCommand, setSelectedCommand] = useState11(null);
3331
3538
  const stats = useMemo6(() => computeProjectStats(), []);
3332
3539
  const isNarrow = (stdout.columns ?? 80) < NARROW_THRESHOLD;
3333
- useEffect9(() => {
3540
+ useEffect10(() => {
3334
3541
  if (selectedCommand === "status") {
3335
3542
  const timer = setTimeout(() => exit(), 0);
3336
3543
  return () => clearTimeout(timer);
3337
3544
  }
3338
3545
  }, [selectedCommand, exit]);
3339
3546
  if (selectedCommand === "plan") {
3340
- return /* @__PURE__ */ jsx13(Plan, {});
3547
+ return /* @__PURE__ */ jsx14(Plan, {});
3341
3548
  }
3342
3549
  if (selectedCommand === "build") {
3343
- return /* @__PURE__ */ jsx13(Build, {});
3550
+ return /* @__PURE__ */ jsx14(Build, {});
3551
+ }
3552
+ if (selectedCommand === "resume") {
3553
+ return /* @__PURE__ */ jsx14(Resume, {});
3344
3554
  }
3345
3555
  if (selectedCommand === "status") {
3346
- return /* @__PURE__ */ jsx13(Status, { version: version2 });
3556
+ return /* @__PURE__ */ jsx14(Status, { version: version2 });
3347
3557
  }
3348
3558
  if (selectedCommand === "config") {
3349
- return /* @__PURE__ */ jsx13(ConfigEditor, { version: version2 });
3559
+ return /* @__PURE__ */ jsx14(ConfigEditor, { version: version2 });
3350
3560
  }
3351
- return /* @__PURE__ */ jsxs11(Box12, { flexDirection: "column", gap: 1, children: [
3352
- isNarrow ? /* @__PURE__ */ jsxs11(Box12, { flexDirection: "column", children: [
3353
- /* @__PURE__ */ jsxs11(Text13, { bold: true, color: "#f0a030", children: [
3561
+ return /* @__PURE__ */ jsxs12(Box13, { flexDirection: "column", gap: 1, children: [
3562
+ isNarrow ? /* @__PURE__ */ jsxs12(Box13, { flexDirection: "column", children: [
3563
+ /* @__PURE__ */ jsxs12(Text14, { bold: true, color: "#f0a030", children: [
3354
3564
  "\u{1F439} toby v",
3355
3565
  version2
3356
3566
  ] }),
3357
- stats !== null && /* @__PURE__ */ jsxs11(Text13, { children: [
3358
- /* @__PURE__ */ jsx13(Text13, { dimColor: true, children: "Specs: " }),
3359
- /* @__PURE__ */ jsx13(Text13, { children: stats.totalSpecs }),
3360
- /* @__PURE__ */ jsx13(Text13, { dimColor: true, children: " \xB7 Planned: " }),
3361
- /* @__PURE__ */ jsx13(Text13, { children: stats.planned }),
3362
- /* @__PURE__ */ jsx13(Text13, { dimColor: true, children: " \xB7 Done: " }),
3363
- /* @__PURE__ */ jsx13(Text13, { children: stats.done }),
3364
- /* @__PURE__ */ jsx13(Text13, { dimColor: true, children: " \xB7 Tokens: " }),
3365
- /* @__PURE__ */ jsx13(Text13, { children: formatTokens(stats.totalTokens) })
3567
+ stats !== null && /* @__PURE__ */ jsxs12(Text14, { children: [
3568
+ /* @__PURE__ */ jsx14(Text14, { dimColor: true, children: "Specs: " }),
3569
+ /* @__PURE__ */ jsx14(Text14, { children: stats.totalSpecs }),
3570
+ /* @__PURE__ */ jsx14(Text14, { dimColor: true, children: " \xB7 Planned: " }),
3571
+ /* @__PURE__ */ jsx14(Text14, { children: stats.planned }),
3572
+ /* @__PURE__ */ jsx14(Text14, { dimColor: true, children: " \xB7 Done: " }),
3573
+ /* @__PURE__ */ jsx14(Text14, { children: stats.done }),
3574
+ /* @__PURE__ */ jsx14(Text14, { dimColor: true, children: " \xB7 Tokens: " }),
3575
+ /* @__PURE__ */ jsx14(Text14, { children: formatTokens(stats.totalTokens) })
3366
3576
  ] })
3367
- ] }) : /* @__PURE__ */ jsxs11(Box12, { flexDirection: "row", gap: 2, children: [
3368
- /* @__PURE__ */ jsx13(HamsterWheel, {}),
3369
- /* @__PURE__ */ jsx13(InfoPanel, { version: version2, stats })
3577
+ ] }) : /* @__PURE__ */ jsxs12(Box13, { flexDirection: "row", gap: 2, children: [
3578
+ /* @__PURE__ */ jsx14(HamsterWheel, {}),
3579
+ /* @__PURE__ */ jsx14(InfoPanel, { version: version2, stats })
3370
3580
  ] }),
3371
- /* @__PURE__ */ jsx13(MainMenu, { onSelect: setSelectedCommand })
3581
+ /* @__PURE__ */ jsx14(MainMenu, { onSelect: setSelectedCommand })
3372
3582
  ] });
3373
3583
  }
3374
3584
 
@@ -3394,7 +3604,7 @@ var MEOW_FLAGS = {
3394
3604
  var MEOW_FLAG_NAMES = Object.keys(MEOW_FLAGS);
3395
3605
 
3396
3606
  // src/cli.tsx
3397
- import { jsx as jsx14 } from "react/jsx-runtime";
3607
+ import { jsx as jsx15 } from "react/jsx-runtime";
3398
3608
  function writeUnknownCommandError(command2) {
3399
3609
  process.stderr.write(
3400
3610
  formatErrorWithHint(
@@ -3414,7 +3624,7 @@ var resolvedSpec = cli.flags.specs ?? cli.flags.spec;
3414
3624
  var flags = { ...cli.flags, spec: resolvedSpec };
3415
3625
  var commands = {
3416
3626
  plan: {
3417
- render: (flags2) => /* @__PURE__ */ jsx14(
3627
+ render: (flags2) => /* @__PURE__ */ jsx15(
3418
3628
  Plan,
3419
3629
  {
3420
3630
  spec: flags2.spec,
@@ -3429,7 +3639,7 @@ var commands = {
3429
3639
  waitForExit: true
3430
3640
  },
3431
3641
  build: {
3432
- render: (flags2) => /* @__PURE__ */ jsx14(
3642
+ render: (flags2) => /* @__PURE__ */ jsx15(
3433
3643
  Build,
3434
3644
  {
3435
3645
  spec: flags2.spec,
@@ -3444,7 +3654,7 @@ var commands = {
3444
3654
  waitForExit: true
3445
3655
  },
3446
3656
  init: {
3447
- render: (flags2, _input, version2) => /* @__PURE__ */ jsx14(
3657
+ render: (flags2, _input, version2) => /* @__PURE__ */ jsx15(
3448
3658
  Init,
3449
3659
  {
3450
3660
  version: version2,
@@ -3459,21 +3669,32 @@ var commands = {
3459
3669
  waitForExit: true
3460
3670
  },
3461
3671
  status: {
3462
- render: (flags2, _input, version2) => /* @__PURE__ */ jsx14(Status, { spec: flags2.spec, version: version2 })
3672
+ render: (flags2, _input, version2) => /* @__PURE__ */ jsx15(Status, { spec: flags2.spec, version: version2 })
3673
+ },
3674
+ resume: {
3675
+ render: (flags2) => /* @__PURE__ */ jsx15(
3676
+ Resume,
3677
+ {
3678
+ iterations: flags2.iterations,
3679
+ verbose: flags2.verbose,
3680
+ transcript: flags2.transcript
3681
+ }
3682
+ ),
3683
+ waitForExit: true
3463
3684
  },
3464
3685
  clean: {
3465
- render: (flags2) => /* @__PURE__ */ jsx14(Clean, { force: flags2.force }),
3686
+ render: (flags2) => /* @__PURE__ */ jsx15(Clean, { force: flags2.force }),
3466
3687
  waitForExit: true
3467
3688
  },
3468
3689
  config: {
3469
3690
  render: (_flags, input, version2) => {
3470
3691
  const [, subcommand, ...rest] = input;
3471
- if (!subcommand) return /* @__PURE__ */ jsx14(ConfigEditor, { version: version2 });
3692
+ if (!subcommand) return /* @__PURE__ */ jsx15(ConfigEditor, { version: version2 });
3472
3693
  if (subcommand === "set" && rest.some((arg) => arg.includes("="))) {
3473
- return /* @__PURE__ */ jsx14(ConfigSetBatch, { pairs: rest.filter((arg) => arg.includes("=")) });
3694
+ return /* @__PURE__ */ jsx15(ConfigSetBatch, { pairs: rest.filter((arg) => arg.includes("=")) });
3474
3695
  }
3475
3696
  const [configKey, value] = rest;
3476
- return /* @__PURE__ */ jsx14(
3697
+ return /* @__PURE__ */ jsx15(
3477
3698
  Config,
3478
3699
  {
3479
3700
  subcommand,
@@ -3502,7 +3723,7 @@ if (cli.flags.help) {
3502
3723
  }
3503
3724
  } else if (!command) {
3504
3725
  if (process.stdin.isTTY) {
3505
- const app = render(/* @__PURE__ */ jsx14(Welcome, { version }));
3726
+ const app = render(/* @__PURE__ */ jsx15(Welcome, { version }));
3506
3727
  await app.waitUntilExit();
3507
3728
  } else {
3508
3729
  process.stdout.write(formatGlobalHelp(version));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@0xtiby/toby",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "AI-assisted development loop engine CLI",
5
5
  "repository": {
6
6
  "type": "git",