@drawcall/create 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/harness.d.ts CHANGED
@@ -2,7 +2,8 @@ import { Context, Effect, Layer } from "effect";
2
2
  import { type HarnessName } from "./constants.js";
3
3
  import { PreflightError } from "./errors.js";
4
4
  import { type CommandResult, Shell } from "./shell.js";
5
- export declare function harnessInvocation(harness: HarnessName, prompt: string, cwd: string, harnessArgs?: ReadonlyArray<string>): {
5
+ export declare function sessionCapable(harness: HarnessName): boolean;
6
+ export declare function harnessInvocation(harness: HarnessName, prompt: string, cwd: string, harnessArgs?: ReadonlyArray<string>, sessionArgs?: ReadonlyArray<string>): {
6
7
  command: string;
7
8
  args: string[];
8
9
  };
@@ -12,9 +13,15 @@ export interface HarnessTurn {
12
13
  readonly cwd: string;
13
14
  readonly harnessArgs?: ReadonlyArray<string>;
14
15
  readonly timeoutMs?: number;
16
+ readonly session?: {
17
+ readonly token?: string;
18
+ };
19
+ }
20
+ export interface HarnessResult extends CommandResult {
21
+ readonly sessionToken?: string;
15
22
  }
16
23
  export interface HarnessService {
17
- readonly runTurn: (turn: HarnessTurn) => Effect.Effect<CommandResult>;
24
+ readonly runTurn: (turn: HarnessTurn) => Effect.Effect<HarnessResult>;
18
25
  readonly select: (requested: HarnessName | undefined) => Effect.Effect<HarnessName, PreflightError>;
19
26
  }
20
27
  declare const Harness_base: Context.TagClass<Harness, "Harness", HarnessService>;
package/dist/harness.js CHANGED
@@ -1,48 +1,140 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { Context, Effect, Layer } from "effect";
2
3
  import { HARNESS_NAMES } from "./constants.js";
3
4
  import { PreflightError } from "./errors.js";
4
5
  import { Shell } from "./shell.js";
6
+ const preassign = (begin, resume) => ({
7
+ begin: () => {
8
+ const id = randomUUID();
9
+ return { args: begin(id), id };
10
+ },
11
+ resume
12
+ });
13
+ // Per-harness session continuity. Mechanisms verified against each harness's docs/source (2026).
14
+ const SESSION_ADAPTERS = {
15
+ // opencode mints `ses_…`; `run --print-logs` surfaces it on the first turn, `run --session <id>`
16
+ // continues it on later turns (validated: context is retained across invocations).
17
+ opencode: {
18
+ begin: () => ({ args: ["--print-logs"] }),
19
+ resume: (id) => ["--session", id],
20
+ capture: (output) => output.match(/\bses_[A-Za-z0-9]{8,}\b/)?.[0]
21
+ },
22
+ // Claude Code: pre-set the id with --session-id, continue with --resume.
23
+ claude: preassign((id) => ["--session-id", id], (id) => ["--resume", id]),
24
+ // Gemini CLI: same shape as Claude.
25
+ gemini: preassign((id) => ["--session-id", id], (id) => ["--resume", id]),
26
+ // Grok CLI: a single --session flag both creates-with-id and resumes.
27
+ grok: preassign((id) => ["--session", id], (id) => ["--session", id]),
28
+ // forge: --conversation-id both starts and resumes a stored conversation.
29
+ forge: preassign((id) => ["--conversation-id", id], (id) => ["--conversation-id", id]),
30
+ // pi: --session takes the id for both start and resume.
31
+ pi: preassign((id) => ["--session", id], (id) => ["--session", id]),
32
+ // codex mints a thread id; `exec --json` surfaces it, `exec resume <id>` continues it.
33
+ codex: {
34
+ begin: () => ({ args: ["--json"] }),
35
+ resume: (id) => ["resume", id],
36
+ capture: (output) => output.match(/\bthread_[A-Za-z0-9-]{8,}\b/)?.[0]
37
+ }
38
+ };
39
+ // Whether a harness can continue a session across build turns. All currently-supported harnesses can;
40
+ // the supervisor only enables same-session for those that do, and degrades to cold turns otherwise.
41
+ export function sessionCapable(harness) {
42
+ return SESSION_ADAPTERS[harness] !== undefined;
43
+ }
5
44
  // How each harness is invoked headlessly: the flags that make it run one non-interactive turn and
6
- // auto-approve the tool calls a build needs (writes, npm, proof runs). This is hard-won per-harness
7
- // knowledge, kept verbatim from the original the rewrite changes the orchestration around it, not
8
- // the invocations themselves.
9
- export function harnessInvocation(harness, prompt, cwd, harnessArgs = []) {
45
+ // auto-approve the tool calls a build needs (writes, npm, proof runs). `sessionArgs` are the adapter's
46
+ // continuity flags, placed right after the harness's leading subcommand/mode flags so they apply to
47
+ // this turn. This is hard-won per-harness knowledge — the rewrite changes the orchestration around it.
48
+ export function harnessInvocation(harness, prompt, cwd, harnessArgs = [], sessionArgs = []) {
10
49
  switch (harness) {
11
50
  case "opencode":
12
51
  return {
13
52
  command: "opencode",
14
- args: ["run", "--dangerously-skip-permissions", "--dir", cwd, ...harnessArgs, prompt]
53
+ args: [
54
+ "run",
55
+ "--dangerously-skip-permissions",
56
+ ...sessionArgs,
57
+ "--dir",
58
+ cwd,
59
+ ...harnessArgs,
60
+ prompt
61
+ ]
15
62
  };
16
63
  case "codex":
17
- return { command: "codex", args: ["exec", "--skip-git-repo-check", ...harnessArgs, prompt] };
64
+ return {
65
+ command: "codex",
66
+ args: ["exec", ...sessionArgs, "--skip-git-repo-check", ...harnessArgs, prompt]
67
+ };
18
68
  case "claude":
19
69
  return {
20
70
  command: "claude",
21
- args: ["--print", "--permission-mode", "bypassPermissions", ...harnessArgs, prompt]
71
+ args: [
72
+ "--print",
73
+ ...sessionArgs,
74
+ "--permission-mode",
75
+ "bypassPermissions",
76
+ ...harnessArgs,
77
+ prompt
78
+ ]
22
79
  };
23
80
  case "pi":
24
- return { command: "pi", args: [...harnessArgs, "-p", prompt] };
81
+ return { command: "pi", args: [...sessionArgs, ...harnessArgs, "-p", prompt] };
25
82
  case "gemini":
26
83
  return {
27
84
  command: "gemini",
28
- args: ["--approval-mode", "yolo", "--skip-trust", ...harnessArgs, "--prompt", prompt]
85
+ args: [
86
+ "--approval-mode",
87
+ "yolo",
88
+ "--skip-trust",
89
+ ...sessionArgs,
90
+ ...harnessArgs,
91
+ "--prompt",
92
+ prompt
93
+ ]
29
94
  };
30
95
  case "grok":
31
96
  return {
32
97
  command: "grok",
33
- args: ["--always-approve", "--output-format", "plain", ...harnessArgs, "-p", prompt]
98
+ args: [
99
+ "--always-approve",
100
+ "--output-format",
101
+ "plain",
102
+ ...sessionArgs,
103
+ ...harnessArgs,
104
+ "-p",
105
+ prompt
106
+ ]
34
107
  };
35
108
  case "forge":
36
- return { command: "forge", args: ["-C", cwd, ...harnessArgs, "-p", prompt] };
109
+ return { command: "forge", args: ["-C", cwd, ...sessionArgs, ...harnessArgs, "-p", prompt] };
37
110
  }
38
111
  }
39
112
  export class Harness extends Context.Tag("Harness")() {
40
113
  }
41
114
  const live = (shell) => ({
42
- runTurn: ({ harness, prompt, cwd, harnessArgs, timeoutMs }) => {
43
- const { command, args } = harnessInvocation(harness, prompt, cwd, harnessArgs);
44
- return shell.run({ command, args, cwd, timeoutMs });
45
- },
115
+ runTurn: ({ harness, prompt, cwd, harnessArgs, timeoutMs, session }) => Effect.gen(function* () {
116
+ const adapter = session ? SESSION_ADAPTERS[harness] : undefined;
117
+ let sessionArgs = [];
118
+ let begunId;
119
+ if (adapter && session) {
120
+ if (session.token) {
121
+ sessionArgs = adapter.resume(session.token);
122
+ }
123
+ else {
124
+ const begun = adapter.begin();
125
+ sessionArgs = begun.args;
126
+ begunId = begun.id;
127
+ }
128
+ }
129
+ const { command, args } = harnessInvocation(harness, prompt, cwd, harnessArgs, sessionArgs);
130
+ const result = yield* shell.run({ command, args, cwd, timeoutMs });
131
+ // The continuation token: the one we resumed, the one we minted (preassign), or — for a capture
132
+ // harness's first turn — whatever id it surfaced in its output. Undefined keeps the next turn cold.
133
+ const sessionToken = session?.token ??
134
+ begunId ??
135
+ (session && adapter?.capture ? adapter.capture(result.output) : undefined);
136
+ return { ...result, sessionToken };
137
+ }),
46
138
  select: (requested) => Effect.gen(function* () {
47
139
  if (requested) {
48
140
  if (yield* shell.exists(requested))
package/dist/shell.js CHANGED
@@ -1,5 +1,4 @@
1
1
  import { spawn } from "node:child_process";
2
- import { createInterface } from "node:readline";
3
2
  import { delimiter, dirname, join, resolve } from "node:path";
4
3
  import which from "which";
5
4
  import { Context, Effect, Layer } from "effect";
@@ -64,16 +63,43 @@ const live = (env, logger) => ({
64
63
  env: buildSubprocessEnv(cwd, env),
65
64
  stdio: ["ignore", "pipe", "pipe"]
66
65
  });
67
- // Read both streams a line at a time so a timestamp only ever prefixes a whole line, and keep
68
- // every line for classification.
66
+ const onLine = (line) => {
67
+ captured.push(line);
68
+ Effect.runSync(logger.captured(line));
69
+ };
70
+ // Capture per stream, collapsing carriage-return redraws to their final frame: a harness
71
+ // progress spinner ("⠧ Processing 16m") rewrites one line with \r thousands of times, which
72
+ // would otherwise flood the log and bury real events. Emitting only on \n (a \r resets the
73
+ // line buffer) keeps each line whole and turns a slow step into a visible time gap between
74
+ // log lines instead of spinner spam. Real output lines are unaffected.
75
+ const flushers = [];
69
76
  for (const stream of [child.stdout, child.stderr]) {
70
77
  if (!stream)
71
78
  continue;
72
- createInterface({ input: stream }).on("line", (line) => {
73
- captured.push(line);
74
- Effect.runSync(logger.captured(line));
79
+ let pending = "";
80
+ stream.setEncoding("utf8");
81
+ stream.on("data", (chunk) => {
82
+ for (const ch of chunk) {
83
+ if (ch === "\n") {
84
+ onLine(pending);
85
+ pending = "";
86
+ }
87
+ else if (ch === "\r") {
88
+ pending = "";
89
+ }
90
+ else {
91
+ pending += ch;
92
+ }
93
+ }
94
+ });
95
+ flushers.push(() => {
96
+ if (pending.length > 0) {
97
+ onLine(pending);
98
+ pending = "";
99
+ }
75
100
  });
76
101
  }
102
+ const flushStreams = () => flushers.forEach((flush) => flush());
77
103
  let timedOut = false;
78
104
  let killTimer;
79
105
  const timeout = timeoutMs === undefined
@@ -92,6 +118,7 @@ const live = (env, logger) => ({
92
118
  };
93
119
  child.once("error", (error) => {
94
120
  cleanup();
121
+ flushStreams();
95
122
  // spawn failed (command not found, etc.): surface as a non-zero exit with the error text so
96
123
  // the caller can classify it like any other failure.
97
124
  captured.push(String(error.message ?? error));
@@ -99,6 +126,7 @@ const live = (env, logger) => ({
99
126
  });
100
127
  child.once("exit", (code, signal) => {
101
128
  cleanup();
129
+ flushStreams();
102
130
  const exitCode = timedOut ? TIMEOUT_EXIT_CODE : signal ? 1 : (code ?? 1);
103
131
  const elapsed = Math.round((Date.now() - startedAt) / 1000);
104
132
  const failure = !timedOut && exitCode !== 0 ? ` — exit ${exitCode}` : "";
package/dist/stages.d.ts CHANGED
@@ -14,5 +14,7 @@ export interface RunContext {
14
14
  }
15
15
  type StageEnv = Harness | Scaffold | Git;
16
16
  export declare const runStage: (stage: Exclude<PipelineStage, "build">, ctx: RunContext, runMeta: RunMeta | undefined) => Effect.Effect<void, RunFailure, StageEnv>;
17
- export declare const buildTurn: (ctx: RunContext, turnNumber: number) => Effect.Effect<void, RunFailure, StageEnv>;
17
+ export declare const buildTurn: (ctx: RunContext, turnNumber: number, session?: {
18
+ readonly token?: string;
19
+ }) => Effect.Effect<string | undefined, RunFailure, StageEnv>;
18
20
  export {};
package/dist/stages.js CHANGED
@@ -12,16 +12,17 @@ import { buildBuildPrompt, buildGoalPrompt, buildPlanPrompt, buildSurveyAssetsPr
12
12
  // Run one harness turn for a stage, turning a non-zero exit / timeout into a classified, explainable
13
13
  // StageFailure. A clean exit is success — the stage's own assertions (did GOAL.md appear?) catch the
14
14
  // "ran fine but produced nothing" case separately.
15
- const turn = (stage, prompt, ctx) => Effect.flatMap(Harness, (harness) => harness
15
+ const turn = (stage, prompt, ctx, session) => Effect.flatMap(Harness, (harness) => harness
16
16
  .runTurn({
17
17
  harness: ctx.harness,
18
18
  prompt,
19
19
  cwd: ctx.cwd,
20
20
  harnessArgs: ctx.harnessArgs,
21
- timeoutMs: ctx.timeoutMs
21
+ timeoutMs: ctx.timeoutMs,
22
+ session
22
23
  })
23
24
  .pipe(Effect.flatMap((r) => r.exitCode === 0
24
- ? Effect.void
25
+ ? Effect.succeed(r)
25
26
  : Effect.fail(new StageFailure({
26
27
  stage,
27
28
  reason: classifyHarnessOutput(r.output, r.exitCode, r.timedOut),
@@ -96,10 +97,12 @@ const plan = (ctx) => Effect.gen(function* () {
96
97
  // genuinely consumed it deletes PLAN.md, which is our "done" signal. The checkpoint is --allow-empty,
97
98
  // so a turn that committed real work and a turn that did nothing both leave a marker — and the
98
99
  // supervisor tells them apart by the tree hash, not by whether a commit appeared.
99
- const build = (ctx, turnNumber) => Effect.gen(function* () {
100
- yield* turn("build", buildBuildPrompt(ctx.prompt, turnNumber), ctx);
100
+ const build = (ctx, turnNumber, session) => Effect.gen(function* () {
101
+ const result = yield* turn("build", buildBuildPrompt(ctx.prompt, turnNumber), ctx, session);
101
102
  const done = !existsSync(join(ctx.cwd, PLAN_FILE));
102
103
  yield* checkpoint(ctx.cwd, done ? "done" : "build");
104
+ // The token to continue this build's warm session next turn (undefined keeps the next turn cold).
105
+ return result.sessionToken;
103
106
  });
104
107
  // Dispatch a non-build stage. Build is driven directly by the supervisor (it owns the turn number
105
108
  // and budget), so it is intentionally excluded from this signature.
@@ -117,4 +120,4 @@ export const runStage = (stage, ctx, runMeta) => {
117
120
  return plan(ctx);
118
121
  }
119
122
  };
120
- export const buildTurn = (ctx, turnNumber) => build(ctx, turnNumber);
123
+ export const buildTurn = (ctx, turnNumber, session) => build(ctx, turnNumber, session);
@@ -17,6 +17,7 @@ export interface RunOptions {
17
17
  readonly harnessTimeoutMs?: number;
18
18
  readonly maxTurns?: number;
19
19
  readonly skipTemplate?: boolean;
20
+ readonly buildSession?: boolean;
20
21
  readonly backoff?: (attempt: number) => Duration.Duration;
21
22
  }
22
23
  export type Outcome = "done" | "stuck" | "budget-exhausted";
@@ -5,7 +5,7 @@ import { humanReason } from "./classify.js";
5
5
  import { MAX_BUILD_TURNS } from "./constants.js";
6
6
  import { PreflightError, reasonOf } from "./errors.js";
7
7
  import { Git } from "./git.js";
8
- import { Harness } from "./harness.js";
8
+ import { Harness, sessionCapable } from "./harness.js";
9
9
  import { Logger } from "./logger.js";
10
10
  import { planNext } from "./resume.js";
11
11
  import { buildTurn, runStage } from "./stages.js";
@@ -55,12 +55,10 @@ export const run = (options) => Effect.gen(function* () {
55
55
  maxTurns,
56
56
  skipTemplate: ctx.skipTemplate
57
57
  };
58
- return yield* loop(ctx, runMeta, maxTurns, options.backoff ?? backoff, {
59
- staleAttempts: 0,
60
- errors: []
61
- });
58
+ const sessionOn = options.buildSession ?? process.env.DC_BUILD_SESSION !== "off";
59
+ return yield* loop(ctx, runMeta, maxTurns, options.backoff ?? backoff, { staleAttempts: 0, errors: [] }, sessionOn, undefined);
62
60
  });
63
- const loop = (ctx, runMeta, maxTurns, backoffFor, state) => Effect.gen(function* () {
61
+ const loop = (ctx, runMeta, maxTurns, backoffFor, state, sessionOn, buildToken) => Effect.gen(function* () {
64
62
  const git = yield* Git;
65
63
  const before = yield* fingerprint(ctx.cwd);
66
64
  const step = yield* planNext(ctx.cwd);
@@ -72,10 +70,18 @@ const loop = (ctx, runMeta, maxTurns, backoffFor, state) => Effect.gen(function*
72
70
  return finish(ctx.cwd, "budget-exhausted", state, turns);
73
71
  }
74
72
  yield* Console.log(`→ ${label(step.stage, ctx.harness)}`);
73
+ // Same-session only spans consecutive build turns, and only for harnesses that support it.
74
+ const session = step.stage === "build" && sessionOn && sessionCapable(ctx.harness)
75
+ ? { token: buildToken }
76
+ : undefined;
75
77
  const work = step.stage === "build"
76
- ? buildTurn(ctx, turns + 1)
77
- : runStage(step.stage, ctx, step.stage === "scaffold" ? runMeta : undefined);
78
+ ? buildTurn(ctx, turns + 1, session)
79
+ : runStage(step.stage, ctx, step.stage === "scaffold" ? runMeta : undefined).pipe(Effect.as(undefined));
78
80
  const outcome = yield* Effect.either(work);
81
+ // The token to continue with next turn — only from a successful build turn. Any failure runs
82
+ // resetClean (the tree returns to the last checkpoint), which makes the warm session inconsistent
83
+ // with the working tree, so we drop it and the next build turn begins a fresh session.
84
+ const nextToken = outcome._tag === "Right" && step.stage === "build" ? outcome.right : undefined;
79
85
  let reason;
80
86
  if (outcome._tag === "Left") {
81
87
  reason = reasonOf(outcome.left);
@@ -93,8 +99,8 @@ const loop = (ctx, runMeta, maxTurns, backoffFor, state) => Effect.gen(function*
93
99
  if (outcome._tag === "Right")
94
100
  yield* Console.log(`✓ ${label(step.stage, ctx.harness)}`);
95
101
  // Forward progress — reset the stale counter and the error accumulator; only the failures that
96
- // actually precede a stop should appear in its report.
97
- return yield* loop(ctx, runMeta, maxTurns, backoffFor, { staleAttempts: 0, errors: [] });
102
+ // actually precede a stop should appear in its report. Carry the warm-session token forward.
103
+ return yield* loop(ctx, runMeta, maxTurns, backoffFor, { staleAttempts: 0, errors: [] }, sessionOn, nextToken);
98
104
  }
99
105
  const staleAttempts = state.staleAttempts + 1;
100
106
  const errors = reason ? [...state.errors, reason] : state.errors;
@@ -105,7 +111,7 @@ const loop = (ctx, runMeta, maxTurns, backoffFor, state) => Effect.gen(function*
105
111
  }
106
112
  yield* note(`no progress; attempt ${staleAttempts}/${MAX_STALE_ATTEMPTS}, backing off`);
107
113
  yield* Effect.sleep(backoffFor(staleAttempts));
108
- return yield* loop(ctx, runMeta, maxTurns, backoffFor, { staleAttempts, errors });
114
+ return yield* loop(ctx, runMeta, maxTurns, backoffFor, { staleAttempts, errors }, sessionOn, undefined);
109
115
  });
110
116
  const finish = (projectDir, outcome, state, buildTurns) => ({
111
117
  projectDir,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drawcall/create",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "type": "module",
5
5
  "description": "Create projects with an installed local harness.",
6
6
  "license": "MIT",