tines 0.0.315 → 0.0.316

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 (3) hide show
  1. package/README.md +2 -1
  2. package/dist/index.js +651 -79
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -135,6 +135,7 @@ TINES_API_KEY=tines_… tines runner install --name laptop --harness claude-code
135
135
 
136
136
  registers this machine as a local runner and installs the daemon as a launchd/systemd
137
137
  service that polls for work and keeps itself updated (`tines runner daemon` with the same
138
- flags runs it in the foreground instead). See
138
+ flags runs it in the foreground instead). `--harness` is `claude-code`, `codex`, `pi` or
139
+ `custom`. See
139
140
  [docs/runner-daemon.md](https://github.com/tbuckley/tines/blob/main/docs/runner-daemon.md)
140
141
  for the flags, token rotation, and what the service does.
package/dist/index.js CHANGED
@@ -5848,6 +5848,7 @@ function isSupportedCodexRolloutVersion(version) {
5848
5848
  // ../shared/src/effort.ts
5849
5849
  var EFFORT_TOKEN_PATTERN = /^[a-z][a-z0-9_-]{0,31}$/;
5850
5850
  var EFFORT_CAPABILITIES_MAX_BYTES = 64 * 1024;
5851
+ var EFFORT_CAPABILITIES_MAX_MODELS = 256;
5851
5852
  var RECOGNIZED_EFFORT_VALUES = ["low", "medium", "high", "xhigh", "max", "ultra"];
5852
5853
  function admitEffort(allowed, value, assertable) {
5853
5854
  if (allowed !== null)
@@ -7917,7 +7918,7 @@ function register3(program3) {
7917
7918
  const blockedBy = await Promise.all(
7918
7919
  (opts.blockedBy ?? []).map(async (ref) => (await resolveIssue(api, ref)).id)
7919
7920
  );
7920
- const blocks = await Promise.all(
7921
+ const blocks2 = await Promise.all(
7921
7922
  (opts.blocks ?? []).map(async (ref) => (await resolveIssue(api, ref)).id)
7922
7923
  );
7923
7924
  const duplicateOf = opts.duplicateOf ? (await resolveIssue(api, opts.duplicateOf)).id : void 0;
@@ -7929,7 +7930,7 @@ function register3(program3) {
7929
7930
  schedule: schedule2,
7930
7931
  labels: opts.label,
7931
7932
  ...blockedBy.length > 0 ? { blocked_by: blockedBy } : {},
7932
- ...blocks.length > 0 ? { blocks } : {},
7933
+ ...blocks2.length > 0 ? { blocks: blocks2 } : {},
7933
7934
  ...duplicateOf ? { duplicate_of: duplicateOf } : {}
7934
7935
  });
7935
7936
  if (opts.json) return printJson(issue);
@@ -9474,6 +9475,7 @@ import {
9474
9475
  createWriteStream,
9475
9476
  existsSync as existsSync6,
9476
9477
  mkdirSync as mkdirSync5,
9478
+ readdirSync as readdirSync3,
9477
9479
  readFileSync as readFileSync10,
9478
9480
  realpathSync,
9479
9481
  rmSync as rmSync4,
@@ -9482,7 +9484,7 @@ import {
9482
9484
  writeFileSync as writeFileSync4
9483
9485
  } from "node:fs";
9484
9486
  import { randomUUID as randomUUID2 } from "node:crypto";
9485
- import { dirname as dirname4, join as join7 } from "node:path";
9487
+ import { dirname as dirname4, join as join8 } from "node:path";
9486
9488
  import { StringDecoder as StringDecoder2 } from "node:string_decoder";
9487
9489
 
9488
9490
  // src/version.ts
@@ -9507,6 +9509,7 @@ function copySummary(value) {
9507
9509
  return {
9508
9510
  ...value,
9509
9511
  ...value.usage ? { usage: { ...value.usage } } : {},
9512
+ ...value.harnessOutcome ? { harnessOutcome: { ...value.harnessOutcome } } : {},
9510
9513
  ...value.pricingEvidence ? {
9511
9514
  pricingEvidence: {
9512
9515
  ...value.pricingEvidence,
@@ -9851,6 +9854,284 @@ var CodexStreamRenderer = class {
9851
9854
  }
9852
9855
  };
9853
9856
 
9857
+ // src/daemon/pi-stream.ts
9858
+ function blocks(content) {
9859
+ return Array.isArray(content) ? content : [];
9860
+ }
9861
+ function blockText(content) {
9862
+ return (content ?? []).map((block) => typeof block?.text === "string" ? block.text : "").join("").trim();
9863
+ }
9864
+ function failedStop(message3) {
9865
+ return message3.stopReason === "error" || message3.stopReason === "aborted";
9866
+ }
9867
+ function toolLine(event) {
9868
+ const name2 = event.toolName ?? "tool";
9869
+ const args = event.args && typeof event.args === "object" ? event.args : {};
9870
+ if (name2 === "bash" && typeof args.command === "string") {
9871
+ return `[tool] bash: ${clip(args.command, 300)}`;
9872
+ }
9873
+ if (typeof args.path === "string") return `[tool] ${name2} ${clip(args.path, 300)}`;
9874
+ const rest = event.args === void 0 ? "" : JSON.stringify(event.args);
9875
+ return `[tool] ${name2} ${clip(rest, 300)}`.trimEnd();
9876
+ }
9877
+ function renderPiEvent(event) {
9878
+ switch (event.type) {
9879
+ case "session":
9880
+ return ["[session] started"];
9881
+ case "message_end": {
9882
+ if (event.message?.role !== "assistant") return [];
9883
+ const lines2 = [];
9884
+ for (const block of blocks(event.message.content)) {
9885
+ if (block.type === "text" && block.text?.trim()) {
9886
+ lines2.push(`[agent] ${clip(block.text.trim(), 2e3)}`);
9887
+ }
9888
+ }
9889
+ return lines2;
9890
+ }
9891
+ case "tool_execution_start":
9892
+ return [toolLine(event)];
9893
+ case "tool_execution_end": {
9894
+ const exitCode = event.result?.structuredContent?.exit_code;
9895
+ if (!event.isError && !(typeof exitCode === "number" && exitCode !== 0)) return [];
9896
+ const output = blockText(event.result?.content) || (event.result?.structuredContent?.output ?? "").trim();
9897
+ return [`[tool] ${event.toolName ?? "tool"} failed: ${clip(output, 300)}`];
9898
+ }
9899
+ case "auto_retry_start":
9900
+ return [
9901
+ `[retry] attempt ${event.attempt ?? "?"}/${event.maxAttempts ?? "?"}: ${clip(event.errorMessage ?? "", 300)}`
9902
+ ];
9903
+ case "auto_retry_end":
9904
+ if (event.success !== false) return [];
9905
+ return [`[error] ${clip(event.finalError ?? "retries exhausted", 2e3)}`];
9906
+ default:
9907
+ return [];
9908
+ }
9909
+ }
9910
+ function messageChars(message3) {
9911
+ let chars = typeof message3.content === "string" ? message3.content.length : 0;
9912
+ for (const block of blocks(message3.content)) {
9913
+ if (typeof block.text === "string") chars += block.text.length;
9914
+ if (typeof block.thinking === "string") chars += block.thinking.length;
9915
+ if (block.type === "toolCall") {
9916
+ chars += (block.name ?? "").length + JSON.stringify(block.arguments ?? {}).length;
9917
+ }
9918
+ }
9919
+ for (const section of Object.values(message3.sections ?? {})) {
9920
+ if (typeof section === "string") chars += section.length;
9921
+ }
9922
+ if (message3.toolsAdded !== void 0) chars += JSON.stringify(message3.toolsAdded).length;
9923
+ return chars;
9924
+ }
9925
+ function classifyFailure(detail) {
9926
+ if (/^429\b/.test(detail)) return { kind: "rate_limited", detail };
9927
+ if (/^5\d\d\b/.test(detail) || /Connection error|fetch failed|ECONNREFUSED/.test(detail)) {
9928
+ return { kind: "provider_error", detail };
9929
+ }
9930
+ return { kind: "error", detail };
9931
+ }
9932
+ var UNSPOOLED_PI_EVENTS = /* @__PURE__ */ new Set(["message_update", "message_start", "tool_execution_update"]);
9933
+ function spooledPiLine(line) {
9934
+ let type = /^\s*\{"type":"([A-Za-z_]+)"/.exec(line)?.[1];
9935
+ if (type === void 0 && line.trimStart().startsWith("{")) {
9936
+ try {
9937
+ const event = JSON.parse(line);
9938
+ if (typeof event?.type === "string") type = event.type;
9939
+ } catch {
9940
+ return line;
9941
+ }
9942
+ }
9943
+ if (type === void 0) return line;
9944
+ if (UNSPOOLED_PI_EVENTS.has(type)) return null;
9945
+ return type === "agent_end" ? '{"type":"agent_end"}' : line;
9946
+ }
9947
+ var PiRawSpoolFilter = class {
9948
+ pending = "";
9949
+ /** Feeds a chunk in; returns the complete lines to spool, newline-terminated. */
9950
+ write(chunk) {
9951
+ this.pending += chunk;
9952
+ let out = "";
9953
+ let nl = this.pending.indexOf("\n");
9954
+ while (nl !== -1) {
9955
+ const kept = spooledPiLine(this.pending.slice(0, nl));
9956
+ if (kept !== null) out += `${kept}
9957
+ `;
9958
+ this.pending = this.pending.slice(nl + 1);
9959
+ nl = this.pending.indexOf("\n");
9960
+ }
9961
+ return out;
9962
+ }
9963
+ /** The trailing partial line, if the stream ended without a newline. */
9964
+ end() {
9965
+ const last = this.pending;
9966
+ this.pending = "";
9967
+ return last ? spooledPiLine(last) ?? "" : "";
9968
+ }
9969
+ };
9970
+ var PiStreamRenderer = class {
9971
+ constructor(emit, opts = {}) {
9972
+ this.emit = emit;
9973
+ this.opts = opts;
9974
+ }
9975
+ emit;
9976
+ opts;
9977
+ pending = "";
9978
+ finished = false;
9979
+ providerSessionId;
9980
+ numTurns = 0;
9981
+ tokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
9982
+ cost = 0;
9983
+ /** Assistant messages that ended, and those of them that were real answers. */
9984
+ assistantMessages = 0;
9985
+ answered = 0;
9986
+ lastAssistant = null;
9987
+ lastRetryFailure = null;
9988
+ appliedEffort;
9989
+ /** The estimate's running totals, in characters. */
9990
+ contextChars = 0;
9991
+ estimatedInputChars = 0;
9992
+ estimatedOutputChars = 0;
9993
+ write(chunk) {
9994
+ this.pending += chunk;
9995
+ let nl = this.pending.indexOf("\n");
9996
+ while (nl !== -1) {
9997
+ this.line(this.pending.slice(0, nl));
9998
+ this.pending = this.pending.slice(nl + 1);
9999
+ nl = this.pending.indexOf("\n");
10000
+ }
10001
+ }
10002
+ /**
10003
+ * Renders any trailing partial line, then — when the model server reported
10004
+ * no usage at all — the labelled estimate. Call once the harness has exited.
10005
+ */
10006
+ finish() {
10007
+ if (this.finished) return;
10008
+ this.finished = true;
10009
+ if (this.pending) {
10010
+ this.line(this.pending);
10011
+ this.pending = "";
10012
+ }
10013
+ if (this.answered > 0 && !this.measured()) {
10014
+ this.emit(
10015
+ `[usage] the model server reported no token usage; rough estimate ~${Math.round(this.estimatedInputChars / 4)} input / ~${Math.round(this.estimatedOutputChars / 4)} output tokens (characters \xF7 4, not recorded)${this.opts.resumed ? "; this run's messages only" : ""}
10016
+ `
10017
+ );
10018
+ }
10019
+ }
10020
+ summary() {
10021
+ const usage2 = this.usage();
10022
+ return copySummary({
10023
+ ...this.providerSessionId ? { providerSessionId: this.providerSessionId } : {},
10024
+ ...usage2 ? { usage: usage2 } : {},
10025
+ ...this.numTurns > 0 ? { numTurns: this.numTurns } : {},
10026
+ harnessOutcome: this.outcome(),
10027
+ ...this.appliedEffort ? { appliedEffort: this.appliedEffort } : {}
10028
+ });
10029
+ }
10030
+ measured() {
10031
+ return this.cost > 0 || Object.values(this.tokens).some((value) => value > 0);
10032
+ }
10033
+ /**
10034
+ * Nothing when every sum is zero: a local model server that reports no
10035
+ * usage must not be recorded as a run that used zero tokens. Tokens alone
10036
+ * carry no `cost_source` — `none` beside tokens reads as inconsistent to
10037
+ * usage accounting — and Pi's own cost is passed on only when it has one.
10038
+ */
10039
+ usage() {
10040
+ if (!this.measured()) return void 0;
10041
+ return {
10042
+ input_tokens: this.tokens.input,
10043
+ output_tokens: this.tokens.output,
10044
+ cache_read_tokens: this.tokens.cacheRead,
10045
+ cache_write_tokens: this.tokens.cacheWrite,
10046
+ ...this.cost > 0 ? { cost_usd: this.cost, cost_source: "provider" } : {}
10047
+ };
10048
+ }
10049
+ outcome() {
10050
+ const last = this.lastAssistant;
10051
+ if (!last) return { kind: "error", detail: "pi ended without an assistant message" };
10052
+ if (!failedStop(last)) return { kind: "ok" };
10053
+ return classifyFailure(last.errorMessage || this.lastRetryFailure || last.stopReason);
10054
+ }
10055
+ line(raw) {
10056
+ const trimmed = raw.trim();
10057
+ if (!trimmed) return;
10058
+ if (!trimmed.startsWith("{")) {
10059
+ this.emit(`${raw}
10060
+ `);
10061
+ return;
10062
+ }
10063
+ let event;
10064
+ try {
10065
+ event = JSON.parse(trimmed);
10066
+ } catch {
10067
+ this.emit(`${raw}
10068
+ `);
10069
+ return;
10070
+ }
10071
+ if (!event || typeof event !== "object" || Array.isArray(event)) return;
10072
+ for (const line of this.collect(event)) this.emit(`${line}
10073
+ `);
10074
+ for (const line of renderPiEvent(event)) this.emit(`${line}
10075
+ `);
10076
+ }
10077
+ /** Updates the summary; returns the lines only the renderer's state can write. */
10078
+ collect(event) {
10079
+ switch (event.type) {
10080
+ case "session":
10081
+ if (!this.providerSessionId && validProviderSessionId(event.id)) {
10082
+ this.providerSessionId = event.id;
10083
+ }
10084
+ return [];
10085
+ case "turn_end":
10086
+ this.numTurns++;
10087
+ return [];
10088
+ case "auto_retry_end":
10089
+ if (event.success === false) this.lastRetryFailure = event.finalError ?? null;
10090
+ return [];
10091
+ case "message_end":
10092
+ return event.message && typeof event.message === "object" ? this.collectMessage(event.message) : [];
10093
+ default:
10094
+ return [];
10095
+ }
10096
+ }
10097
+ collectMessage(message3) {
10098
+ const chars = messageChars(message3);
10099
+ if (message3.role !== "assistant") {
10100
+ if (message3.role === "toolResult") this.addUsage(message3.usage);
10101
+ this.contextChars += chars;
10102
+ return [];
10103
+ }
10104
+ const lines2 = [];
10105
+ if (this.assistantMessages++ === 0 && message3.model) {
10106
+ const model = message3.provider ? `${message3.provider}/${message3.model}` : message3.model;
10107
+ lines2.push(
10108
+ `[session] model ${model}${message3.thinkingLevel ? ` thinking=${message3.thinkingLevel}` : ""}`
10109
+ );
10110
+ }
10111
+ this.lastAssistant = message3;
10112
+ this.addUsage(message3.usage);
10113
+ if (failedStop(message3)) return lines2;
10114
+ this.answered++;
10115
+ this.estimatedInputChars += this.contextChars;
10116
+ this.estimatedOutputChars += chars;
10117
+ this.contextChars += chars;
10118
+ if (this.appliedEffort === void 0 && typeof message3.thinkingLevel === "string") {
10119
+ this.appliedEffort = message3.thinkingLevel;
10120
+ this.opts.onAppliedEffort?.(message3.thinkingLevel);
10121
+ }
10122
+ return lines2;
10123
+ }
10124
+ addUsage(usage2) {
10125
+ if (!usage2 || typeof usage2 !== "object") return;
10126
+ for (const key of ["input", "output", "cacheRead", "cacheWrite"]) {
10127
+ const value = usage2[key];
10128
+ if (validMetric(value)) this.tokens[key] += value;
10129
+ }
10130
+ const cost = usage2.cost?.total;
10131
+ if (validMetric(cost)) this.cost += cost;
10132
+ }
10133
+ };
10134
+
9854
10135
  // src/daemon/codex-rollout.ts
9855
10136
  import { open, opendir, realpath, stat } from "node:fs/promises";
9856
10137
  import { isAbsolute as isAbsolute2, join as join4, relative as relative2, resolve as resolve2, sep as sep2 } from "node:path";
@@ -10299,15 +10580,24 @@ var MAX_STDOUT = 1024 * 1024;
10299
10580
  var DEADLINE_MS = 5e3;
10300
10581
  var EFFORT_CAPABILITIES_TTL_MS = 10 * 6e4;
10301
10582
  var MIN_CLAUDE_EFFORT_VERSION = [2, 1, 258];
10302
- function claudeEffortVersionSupported(version) {
10583
+ var MIN_PI_VERSION = [0, 99, 2];
10584
+ var PI_VERSION_FLOOR = MIN_PI_VERSION.join(".");
10585
+ var PI_DEADLINE_MS = 2e4;
10586
+ function versionAtLeast(version, floor) {
10303
10587
  const parsed = version.match(/(\d+)\.(\d+)\.(\d+)/)?.slice(1).map(Number);
10304
10588
  if (!parsed) return false;
10305
- for (let i = 0; i < MIN_CLAUDE_EFFORT_VERSION.length; i++) {
10306
- if (parsed[i] > MIN_CLAUDE_EFFORT_VERSION[i]) return true;
10307
- if (parsed[i] < MIN_CLAUDE_EFFORT_VERSION[i]) return false;
10589
+ for (let i = 0; i < floor.length; i++) {
10590
+ if (parsed[i] > floor[i]) return true;
10591
+ if (parsed[i] < floor[i]) return false;
10308
10592
  }
10309
10593
  return true;
10310
10594
  }
10595
+ function claudeEffortVersionSupported(version) {
10596
+ return versionAtLeast(version, MIN_CLAUDE_EFFORT_VERSION);
10597
+ }
10598
+ function piVersionSupported(version) {
10599
+ return versionAtLeast(version, MIN_PI_VERSION);
10600
+ }
10311
10601
  function assignmentEffortRejection(assignment, capabilities, harness) {
10312
10602
  if (!assignment.effort) return null;
10313
10603
  if (!capabilities || capabilities.version !== 1 || !("models" in capabilities))
@@ -10473,8 +10763,106 @@ async function discoverCodex(daemonVersion) {
10473
10763
  );
10474
10764
  });
10475
10765
  }
10766
+ async function discoverPi(daemonVersion, deadlineMs = PI_DEADLINE_MS) {
10767
+ let harnessVersion;
10768
+ try {
10769
+ harnessVersion = (await run("pi", ["--version"])).trim().slice(0, 100);
10770
+ } catch (error) {
10771
+ return failure("pi", daemonVersion, error instanceof Error ? error.message : String(error));
10772
+ }
10773
+ if (!piVersionSupported(harnessVersion))
10774
+ return failure(
10775
+ "pi",
10776
+ daemonVersion,
10777
+ `installed pi ${harnessVersion || "unknown"} predates the supported ${MIN_PI_VERSION.join(".")}`
10778
+ );
10779
+ return new Promise((resolve3) => {
10780
+ const child = spawn("pi", ["--mode", "rpc", "--no-session"], {
10781
+ stdio: ["pipe", "pipe", "ignore"]
10782
+ });
10783
+ let buffer = "";
10784
+ let bytes2 = 0;
10785
+ let settled = false;
10786
+ let requestId = 0;
10787
+ let awaiting = null;
10788
+ const finish = (report) => {
10789
+ if (settled) return;
10790
+ settled = true;
10791
+ clearTimeout(timer);
10792
+ child.kill();
10793
+ resolve3(report);
10794
+ };
10795
+ const fail = (reason) => finish(failure("pi", daemonVersion, reason));
10796
+ const request = (command) => new Promise((deliver) => {
10797
+ const id2 = `tines-${++requestId}`;
10798
+ awaiting = { id: id2, deliver };
10799
+ child.stdin.write(`${JSON.stringify({ id: id2, ...command })}
10800
+ `);
10801
+ });
10802
+ const timer = setTimeout(() => fail("pi model discovery timed out"), deadlineMs);
10803
+ child.on("error", (error) => fail(error.message));
10804
+ child.stdin.on("error", (error) => fail(`pi rpc stdin: ${error.message}`));
10805
+ child.on(
10806
+ "close",
10807
+ (code, signal) => fail(`pi rpc exited (${signal ?? `code ${code}`}) before listing models`)
10808
+ );
10809
+ child.stdout.on("data", (chunk) => {
10810
+ bytes2 += chunk.length;
10811
+ if (bytes2 > MAX_STDOUT) return fail("pi model discovery exceeded 1 MiB");
10812
+ buffer += chunk.toString("utf8");
10813
+ for (; ; ) {
10814
+ const newline = buffer.indexOf("\n");
10815
+ if (newline < 0) break;
10816
+ const line = buffer.slice(0, newline).replace(/\r$/, "");
10817
+ buffer = buffer.slice(newline + 1);
10818
+ let record;
10819
+ try {
10820
+ record = JSON.parse(line);
10821
+ } catch {
10822
+ continue;
10823
+ }
10824
+ if (record?.type !== "response" || !awaiting || record.id !== awaiting.id) continue;
10825
+ const { deliver } = awaiting;
10826
+ awaiting = null;
10827
+ deliver(record);
10828
+ }
10829
+ });
10830
+ void (async () => {
10831
+ const listed = await request({ type: "get_available_models" });
10832
+ if (!listed.success)
10833
+ return fail(`pi get_available_models failed: ${String(listed.error ?? "no reason given")}`);
10834
+ const models = [];
10835
+ const names = /* @__PURE__ */ new Set();
10836
+ for (const entry of Array.isArray(listed.data?.models) ? listed.data.models : []) {
10837
+ if (settled || models.length >= EFFORT_CAPABILITIES_MAX_MODELS) break;
10838
+ if (typeof entry?.provider !== "string" || typeof entry?.id !== "string") continue;
10839
+ const model = `${entry.provider}/${entry.id}`;
10840
+ if (model.length > 200 || names.has(model)) continue;
10841
+ const selected = await request({
10842
+ type: "set_model",
10843
+ provider: entry.provider,
10844
+ modelId: entry.id
10845
+ });
10846
+ if (!selected.success) continue;
10847
+ const thinking = await request({ type: "get_available_thinking_levels" });
10848
+ const levels = thinking.success && Array.isArray(thinking.data?.levels) ? thinking.data.levels : [];
10849
+ names.add(model);
10850
+ models.push({ model, efforts: [...new Set(levels.filter(isRecognizedEffort))] });
10851
+ }
10852
+ finish({
10853
+ version: 1,
10854
+ daemon_version: daemonVersion,
10855
+ harness: "pi",
10856
+ harness_version: harnessVersion,
10857
+ catalog_digest: digest(models),
10858
+ models
10859
+ });
10860
+ })();
10861
+ });
10862
+ }
10476
10863
  async function discoverEffortCapabilities(harness, daemonVersion) {
10477
10864
  if (harness === "custom") return void 0;
10865
+ if (harness === "pi") return discoverPi(daemonVersion);
10478
10866
  return harness === "codex" ? discoverCodex(daemonVersion) : discoverClaude(daemonVersion);
10479
10867
  }
10480
10868
  var EffortCapabilityRefresher = class {
@@ -10511,11 +10899,12 @@ var EffortCapabilityRefresher = class {
10511
10899
  // src/daemon/cli-refresh.ts
10512
10900
  import { spawn as spawn2 } from "node:child_process";
10513
10901
  import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync8 } from "node:fs";
10514
- import { dirname as dirname3, join as join5 } from "node:path";
10902
+ import { dirname as dirname3, join as join6 } from "node:path";
10515
10903
 
10516
10904
  // src/daemon/support.ts
10517
- import { delimiter, sep as sep3 } from "node:path";
10518
- var HARNESS_KINDS = ["claude_code", "codex", "custom"];
10905
+ import { delimiter, join as join5, sep as sep3 } from "node:path";
10906
+ var HARNESS_KINDS = ["claude_code", "codex", "pi", "custom"];
10907
+ var PI_SESSION_DIR = ".pi-sessions";
10519
10908
  var KEEP_WORKSPACES_MODES = ["never", "failed", "always"];
10520
10909
  function keepWorkspace(mode, outcome) {
10521
10910
  return mode === "always" || mode === "failed" && outcome === "failed";
@@ -10548,6 +10937,14 @@ function buildHarnessInvocation(spec, input2) {
10548
10937
  input2.prompt
10549
10938
  ]
10550
10939
  };
10940
+ case "pi":
10941
+ return {
10942
+ file: "sh",
10943
+ args: [
10944
+ "-c",
10945
+ `pi --mode json --no-approve --session-dir ${shellQuote(join5(input2.workspace, PI_SESSION_DIR))}${input2.resumeSessionId ? ` --session ${shellQuote(input2.resumeSessionId)}` : ""}${input2.model ? ` --model ${shellQuote(input2.model)}` : ""}${input2.effort ? ` --thinking ${shellQuote(input2.effort)}` : ""}${(input2.skillDirs ?? []).map((dir) => ` --skill ${shellQuote(dir)}`).join("")} < ${shellQuote(input2.promptFile)}`
10946
+ ]
10947
+ };
10551
10948
  case "custom": {
10552
10949
  if (!spec.command) throw new Error("the custom harness needs a --command template");
10553
10950
  return { file: "sh", args: ["-c", expandCommandTemplate(spec.command, input2)] };
@@ -10569,7 +10966,9 @@ function formatLaunchCommand(invocation) {
10569
10966
  function formatLaunchBanner(invocation, input2, meta) {
10570
10967
  const fields = [
10571
10968
  `harness=${meta.harness}`,
10572
- `model=${input2.model ?? "(fixed)"}`,
10969
+ // Pi with no model named is not fixed: it runs whatever its own
10970
+ // settings default to, and the stream's `[session] model` line says which.
10971
+ `model=${input2.model ?? (meta.harness === "pi" ? "(pi default)" : "(fixed)")}`,
10573
10972
  `effort=${input2.effort ?? "(provider-default)"}`,
10574
10973
  `timeout=${meta.timeoutMinutes}m`,
10575
10974
  `cli=${meta.cliVersion}`,
@@ -10599,6 +10998,68 @@ function exitLineForRun(run3, exit) {
10599
10998
  if (run3.settled) return null;
10600
10999
  return formatExitLine({ ...exit, timedOut: run3.timedOut });
10601
11000
  }
11001
+ function classifyExit(facts) {
11002
+ const mask = (text4) => redactSecrets(text4, facts.secrets);
11003
+ if (facts.timedOut) {
11004
+ return {
11005
+ status: "failed",
11006
+ error: `run exceeded the ${facts.timeoutMinutes}m timeout; harness killed`
11007
+ };
11008
+ }
11009
+ if (facts.effortMismatch) return { status: "failed", error: mask(facts.effortMismatch) };
11010
+ if (facts.signal) return { status: "failed", error: `harness killed by ${facts.signal}` };
11011
+ const outcome = facts.harness === "pi" ? facts.harnessOutcome : void 0;
11012
+ if (facts.code !== 0) {
11013
+ if (facts.limited) {
11014
+ const detail = mask(facts.limited.detail);
11015
+ return {
11016
+ status: "failed",
11017
+ error: `rate limited: ${detail}`,
11018
+ judgment: "rate_limited",
11019
+ ...facts.limited.resumeAt !== null ? { resume_at: facts.limited.resumeAt } : {},
11020
+ note: `harness rate limited (${detail})`
11021
+ };
11022
+ }
11023
+ if (facts.providerError) {
11024
+ const detail = mask(facts.providerError.detail);
11025
+ return {
11026
+ status: "failed",
11027
+ error: `provider error: ${detail}`,
11028
+ judgment: "interrupted",
11029
+ note: `transient provider error (${detail})`
11030
+ };
11031
+ }
11032
+ return (outcome && providerVerdict(outcome, mask)) ?? {
11033
+ status: "failed",
11034
+ error: `harness exited with code ${facts.code}`
11035
+ };
11036
+ }
11037
+ if (outcome && outcome.kind !== "ok") {
11038
+ return providerVerdict(outcome, mask) ?? { status: "failed", error: `pi: ${mask(outcome.detail)}` };
11039
+ }
11040
+ return { status: "completed" };
11041
+ }
11042
+ function providerVerdict(outcome, mask) {
11043
+ if (outcome.kind === "rate_limited") {
11044
+ const detail = mask(outcome.detail);
11045
+ return {
11046
+ status: "failed",
11047
+ error: `rate limited: ${detail}`,
11048
+ judgment: "rate_limited",
11049
+ note: `harness rate limited (${detail})`
11050
+ };
11051
+ }
11052
+ if (outcome.kind === "provider_error") {
11053
+ const detail = mask(outcome.detail);
11054
+ return {
11055
+ status: "failed",
11056
+ error: `provider error: ${detail}`,
11057
+ judgment: "interrupted",
11058
+ note: `transient provider error (${detail})`
11059
+ };
11060
+ }
11061
+ return null;
11062
+ }
10602
11063
  var RunTable = class {
10603
11064
  constructor(effects, opts = {}) {
10604
11065
  this.effects = effects;
@@ -10913,14 +11374,14 @@ var SecretRedactor = class {
10913
11374
  var PACKAGE = "tines";
10914
11375
  var NPM_ARGS = ["--min-release-age=0", "--no-audit", "--no-fund", "--loglevel=error"];
10915
11376
  function agentCliPrefix(configDir) {
10916
- return join5(configDir, "cli");
11377
+ return join6(configDir, "cli");
10917
11378
  }
10918
11379
  function binDirOf(prefix) {
10919
- return join5(prefix, "node_modules", ".bin");
11380
+ return join6(prefix, "node_modules", ".bin");
10920
11381
  }
10921
11382
  function installedVersion(prefix) {
10922
11383
  try {
10923
- const pkg = readFileSync8(join5(prefix, "node_modules", PACKAGE, "package.json"), "utf8");
11384
+ const pkg = readFileSync8(join6(prefix, "node_modules", PACKAGE, "package.json"), "utf8");
10924
11385
  const version = JSON.parse(pkg).version;
10925
11386
  return typeof version === "string" ? version : null;
10926
11387
  } catch {
@@ -10929,7 +11390,7 @@ function installedVersion(prefix) {
10929
11390
  }
10930
11391
  function lastGood(prefix) {
10931
11392
  const binDir = binDirOf(prefix);
10932
- if (!existsSync5(join5(binDir, PACKAGE))) return AMBIENT_CLI;
11393
+ if (!existsSync5(join6(binDir, PACKAGE))) return AMBIENT_CLI;
10933
11394
  return { binDir, version: installedVersion(prefix), source: "stale" };
10934
11395
  }
10935
11396
  function runNpm(file, args, cwd, timeoutMs) {
@@ -10974,7 +11435,7 @@ async function installAgentCli(opts) {
10974
11435
  const args = ["install", "--prefix", prefix, `${PACKAGE}@latest`, ...NPM_ARGS];
10975
11436
  let result = await runNpm("npm", args, prefix, timeoutMs);
10976
11437
  if (result.spawnError?.code === "ENOENT") {
10977
- const sibling = join5(dirname3(process.execPath), "npm");
11438
+ const sibling = join6(dirname3(process.execPath), "npm");
10978
11439
  if (existsSync5(sibling)) result = await runNpm(sibling, args, prefix, timeoutMs);
10979
11440
  }
10980
11441
  if (result.code !== 0) {
@@ -11001,12 +11462,12 @@ import { arch, hostname, platform } from "node:os";
11001
11462
 
11002
11463
  // src/daemon/store.ts
11003
11464
  import { readdirSync as readdirSync2, readFileSync as readFileSync9, rmSync as rmSync3, statSync as statSync3 } from "node:fs";
11004
- import { join as join6 } from "node:path";
11465
+ import { join as join7 } from "node:path";
11005
11466
  function credentialsKey(url, name2) {
11006
11467
  return `${url.replace(/\/+$/, "")}#${name2}`;
11007
11468
  }
11008
11469
  function credentialsPath(dir) {
11009
- return join6(dir, "runners.json");
11470
+ return join7(dir, "runners.json");
11010
11471
  }
11011
11472
  function loadRunnerCredentials(dir, url, name2) {
11012
11473
  const all = readJsonFile(credentialsPath(dir));
@@ -11029,7 +11490,7 @@ function clearRunnerCredentials(dir, url, name2) {
11029
11490
  writeJsonFile(path2, all, { secret: true });
11030
11491
  }
11031
11492
  function daemonStatePath(dir, runnerId) {
11032
- return join6(dir, `daemon-state-${runnerId}.json`);
11493
+ return join7(dir, `daemon-state-${runnerId}.json`);
11033
11494
  }
11034
11495
  function loadDaemonState(path2) {
11035
11496
  const state = readJsonFile(path2);
@@ -11042,7 +11503,7 @@ function saveDaemonState(path2, runs) {
11042
11503
  writeJsonFile(path2, { runs });
11043
11504
  }
11044
11505
  function daemonDeclinesPath(dir, runnerId) {
11045
- return join6(dir, `daemon-declines-${runnerId}.json`);
11506
+ return join7(dir, `daemon-declines-${runnerId}.json`);
11046
11507
  }
11047
11508
  function loadDaemonDeclines(path2) {
11048
11509
  const state = readJsonFile(path2);
@@ -11072,10 +11533,10 @@ function processStartTimeMs(pid) {
11072
11533
  }
11073
11534
  }
11074
11535
  function workspacesDir(configDir) {
11075
- return join6(configDir, "workspaces");
11536
+ return join7(configDir, "workspaces");
11076
11537
  }
11077
11538
  function keptMarkerPath(workspace) {
11078
- return join6(workspace, "kept.json");
11539
+ return join7(workspace, "kept.json");
11079
11540
  }
11080
11541
  function writeKeptMarker(workspace, marker) {
11081
11542
  writeJsonFile(keptMarkerPath(workspace), marker);
@@ -11097,7 +11558,7 @@ function listKeptWorkspaces(configDir) {
11097
11558
  }
11098
11559
  const kept = [];
11099
11560
  for (const name2 of names) {
11100
- const path2 = join6(root, name2);
11561
+ const path2 = join7(root, name2);
11101
11562
  const marker = readKeptMarker(path2);
11102
11563
  if (marker) kept.push({ ...marker, path: path2 });
11103
11564
  }
@@ -11134,7 +11595,7 @@ function directorySizeBytes(path2) {
11134
11595
  return 0;
11135
11596
  }
11136
11597
  for (const entry of entries) {
11137
- const child = join6(path2, entry.name);
11598
+ const child = join7(path2, entry.name);
11138
11599
  if (entry.isDirectory()) total += directorySizeBytes(child);
11139
11600
  else if (entry.isFile()) {
11140
11601
  try {
@@ -11213,6 +11674,15 @@ async function uploadRawLog(run3) {
11213
11674
  }
11214
11675
  var log = (message3) => console.log(`[${(/* @__PURE__ */ new Date()).toISOString().slice(11, 19)}] ${message3}`);
11215
11676
  var sleep = (ms) => new Promise((resolve3) => setTimeout(resolve3, ms));
11677
+ function piSessionExists(workspace, sessionId) {
11678
+ try {
11679
+ return readdirSync3(join8(workspace, PI_SESSION_DIR)).some(
11680
+ (name2) => name2.endsWith(`_${sessionId}.jsonl`)
11681
+ );
11682
+ } catch {
11683
+ return false;
11684
+ }
11685
+ }
11216
11686
  function killTree(pid, signal) {
11217
11687
  try {
11218
11688
  process.kill(-pid, signal);
@@ -11298,7 +11768,7 @@ async function runDaemon(opts) {
11298
11768
  const selfUpdate = opts.selfUpdate && opts.cliRefresh && managedInstall;
11299
11769
  if (opts.selfUpdate && opts.cliRefresh) {
11300
11770
  log(
11301
- managedInstall ? `self-update: on \u2014 this daemon (tines ${DAEMON_VERSION}) runs from ${prefix} and will restart once idle after a newer release is installed there` : `self-update: off \u2014 this daemon (tines ${DAEMON_VERSION}) runs from ${selfPath ?? process.argv[1] ?? "?"}, not ${prefix}; launch it from ${join7(prefix, "node_modules", ".bin", "tines")} to have refreshes apply on restart \u2014 \`tines runner install --name ${opts.name} --harness ${opts.harness.replaceAll("_", "-")}\` sets that up as a service (docs/runner-daemon.md)`
11771
+ managedInstall ? `self-update: on \u2014 this daemon (tines ${DAEMON_VERSION}) runs from ${prefix} and will restart once idle after a newer release is installed there` : `self-update: off \u2014 this daemon (tines ${DAEMON_VERSION}) runs from ${selfPath ?? process.argv[1] ?? "?"}, not ${prefix}; launch it from ${join8(prefix, "node_modules", ".bin", "tines")} to have refreshes apply on restart \u2014 \`tines runner install --name ${opts.name} --harness ${opts.harness.replaceAll("_", "-")}\` sets that up as a service (docs/runner-daemon.md)`
11302
11772
  );
11303
11773
  }
11304
11774
  let pendingUpdate = null;
@@ -11471,7 +11941,7 @@ async function runDaemon(opts) {
11471
11941
  `run ${runId}: resume workspace ${assignment.resume.workspace_path} is gone; launching fresh`
11472
11942
  );
11473
11943
  }
11474
- const workspace = resume ? resume.workspace_path : join7(workspacesDir(opts.configDir), runId);
11944
+ const workspace = resume ? resume.workspace_path : join8(workspacesDir(opts.configDir), runId);
11475
11945
  const issueLabel = assignment.run.issue_ref ? `${assignment.run.issue_ref.project_name}/${assignment.run.issue_ref.number}` : void 0;
11476
11946
  const run3 = {
11477
11947
  runId,
@@ -11516,7 +11986,7 @@ async function runDaemon(opts) {
11516
11986
  const currentRepoDirs = assignment.bundle.repos.map((repo) => repo.dir);
11517
11987
  let retainedRepoDirs = [];
11518
11988
  if (resume) {
11519
- const retainedPath = join7(workspace, "repos.json");
11989
+ const retainedPath = join8(workspace, "repos.json");
11520
11990
  let retained;
11521
11991
  try {
11522
11992
  retained = JSON.parse(readFileSync10(retainedPath, "utf8"));
@@ -11539,14 +12009,14 @@ async function runDaemon(opts) {
11539
12009
  rmSync4(workspace, { recursive: true, force: true });
11540
12010
  mkdirSync5(workspace, { recursive: true });
11541
12011
  } else {
11542
- rmSync4(join7(workspace, "kept.json"), { force: true });
12012
+ rmSync4(join8(workspace, "kept.json"), { force: true });
11543
12013
  }
11544
12014
  materializeSkills(workspace, assignment.bundle.skills);
11545
- writeFileSync4(join7(workspace, "prompt.md"), `${assignment.prompt}
12015
+ writeFileSync4(join8(workspace, "prompt.md"), `${assignment.prompt}
11546
12016
  `);
11547
12017
  if (!resume)
11548
12018
  writeFileSync4(
11549
- join7(workspace, "repos.json"),
12019
+ join8(workspace, "repos.json"),
11550
12020
  `${JSON.stringify(assignment.bundle.repos, null, 2)}
11551
12021
  `
11552
12022
  );
@@ -11578,13 +12048,24 @@ async function runDaemon(opts) {
11578
12048
  ` : `warning: no daemon-managed tines CLI; using whatever \`tines\` is on this machine's PATH
11579
12049
  `
11580
12050
  );
12051
+ let resumeSessionId = resume?.provider_session_id;
12052
+ if (opts.harness === "pi" && resumeSessionId && !piSessionExists(workspace, resumeSessionId)) {
12053
+ log(`run ${runId}: pi session ${resumeSessionId} is gone; launching fresh`);
12054
+ run3.batcher.append(`pi session ${resumeSessionId} is gone; launching fresh
12055
+ `);
12056
+ resumeSessionId = void 0;
12057
+ run3.priorTurns = 0;
12058
+ }
11581
12059
  const harnessInput = {
11582
12060
  workspace,
11583
- promptFile: join7(workspace, "prompt.md"),
12061
+ promptFile: join8(workspace, "prompt.md"),
11584
12062
  prompt: assignment.prompt,
11585
12063
  model: assignment.run.model,
11586
12064
  effort: assignment.effort?.value ?? null,
11587
- ...resume ? { resumeSessionId: resume.provider_session_id } : {}
12065
+ ...resumeSessionId ? { resumeSessionId } : {},
12066
+ // Pi does not load `.agents/skills` in a folder it has not been told
12067
+ // to trust, so each materialized skill is named on the command line.
12068
+ ...opts.harness === "pi" ? { skillDirs: assignment.bundle.skills.map((skill) => `.agents/skills/${skill.name}`) } : {}
11588
12069
  };
11589
12070
  const invocation = buildHarnessInvocation(
11590
12071
  { harness: opts.harness, command: opts.command },
@@ -11646,7 +12127,7 @@ async function runDaemon(opts) {
11646
12127
  (event) => limiter.noteStreamEvent(event)
11647
12128
  );
11648
12129
  run3.renderer = renderer;
11649
- const spoolPath = join7(opts.configDir, "rawlogs", `${runId}.ndjson`);
12130
+ const spoolPath = join8(opts.configDir, "rawlogs", `${runId}.ndjson`);
11650
12131
  mkdirSync5(dirname4(spoolPath), { recursive: true });
11651
12132
  run3.rawSpoolPath = spoolPath;
11652
12133
  run3.rawSpool = createWriteStream(spoolPath);
@@ -11667,6 +12148,63 @@ async function runDaemon(opts) {
11667
12148
  run3.rawSpool?.write(rawRedactor.write(text4));
11668
12149
  renderer.write(text4);
11669
12150
  });
12151
+ } else if (opts.harness === "pi") {
12152
+ const renderer = new PiStreamRenderer((line) => run3.batcher.append(line), {
12153
+ resumed: Boolean(resumeSessionId),
12154
+ // Pi clamps `--thinking` to what the model supports without
12155
+ // saying so; the level its first answer records is the proof.
12156
+ // A match confirms an enforced effort. Anything else is a run
12157
+ // at an effort nobody assigned, so it stops here.
12158
+ onAppliedEffort: (level) => {
12159
+ const assigned = assignment.effort?.value;
12160
+ if (!assigned || run3.settled) return;
12161
+ if (level === assigned) {
12162
+ run3.effortEvidence = {
12163
+ status: "confirmed",
12164
+ attempted_effort: assigned,
12165
+ transport: "argv"
12166
+ };
12167
+ } else {
12168
+ const reason = `pi applied thinking level ${level.slice(0, 100)}, not the assigned ${assigned}`;
12169
+ run3.effortEvidence = {
12170
+ status: "rejected",
12171
+ attempted_effort: assigned,
12172
+ transport: "argv",
12173
+ reason,
12174
+ observed_effort: level.slice(0, 100)
12175
+ };
12176
+ run3.effortMismatch = reason;
12177
+ log(`run ${runId}: ${reason}; killing`);
12178
+ run3.batcher.append(`[effort] ${reason}; stopping the run
12179
+ `);
12180
+ if (child.pid) killTree(child.pid, "SIGTERM");
12181
+ if (child.pid) setTimeout(() => killTree(child.pid, "SIGKILL"), 5e3).unref?.();
12182
+ }
12183
+ void client2.appendRunLog(runId, { chunk: "", effort_application: run3.effortEvidence }).catch((err) => log(`run ${runId}: effort evidence rejected: ${message2(err)}`));
12184
+ }
12185
+ });
12186
+ run3.renderer = renderer;
12187
+ const spoolPath = join8(opts.configDir, "rawlogs", `${runId}.ndjson`);
12188
+ mkdirSync5(dirname4(spoolPath), { recursive: true });
12189
+ run3.rawSpoolPath = spoolPath;
12190
+ run3.rawSpool = createWriteStream(spoolPath);
12191
+ run3.rawUpload = (body) => client2.putRunLogRaw(runId, body);
12192
+ const decoder = new StringDecoder2("utf8");
12193
+ const spoolFilter = new PiRawSpoolFilter();
12194
+ const rawRedactor = new SecretRedactor(secretEnvValues);
12195
+ run3.drain = () => {
12196
+ const trailing = decoder.end();
12197
+ run3.rawSpool?.write(
12198
+ rawRedactor.write(spoolFilter.write(trailing) + spoolFilter.end()) + rawRedactor.end()
12199
+ );
12200
+ if (trailing) renderer.write(trailing);
12201
+ renderer.finish();
12202
+ };
12203
+ child.stdout?.on("data", (data) => {
12204
+ const text4 = decoder.write(data);
12205
+ run3.rawSpool?.write(rawRedactor.write(spoolFilter.write(text4)));
12206
+ renderer.write(text4);
12207
+ });
11670
12208
  } else if (opts.harness === "codex") {
11671
12209
  const renderer = new CodexStreamRenderer((line) => run3.batcher.append(line));
11672
12210
  const decoder = new StringDecoder2("utf8");
@@ -11731,38 +12269,21 @@ async function runDaemon(opts) {
11731
12269
  durationMs: Date.now() - (run3.spawnedAt ?? Date.now())
11732
12270
  });
11733
12271
  if (closing) run3.batcher.append(closing);
11734
- if (run3.timedOut) {
11735
- void table2.finishAndCleanup(
11736
- run3,
11737
- "failed",
11738
- `run exceeded the ${assignment.timeout_minutes}m timeout; harness killed`
11739
- );
11740
- } else if (code === 0) {
11741
- void table2.finishAndCleanup(run3, "completed");
11742
- } else {
11743
- const limited = signal ? null : run3.limiter?.signal() ?? null;
11744
- const providerError = signal ? null : run3.limiter?.providerError() ?? null;
11745
- if (limited) {
11746
- const detail = redactSecrets(limited.detail, secretEnvValues);
11747
- log(`run ${runId}: harness rate limited (${detail}); reporting without a strike`);
11748
- void table2.finishAndCleanup(run3, "failed", `rate limited: ${detail}`, {
11749
- judgment: "rate_limited",
11750
- ...limited.resumeAt !== null ? { resume_at: limited.resumeAt } : {}
11751
- });
11752
- } else if (providerError) {
11753
- const detail = redactSecrets(providerError.detail, secretEnvValues);
11754
- log(`run ${runId}: transient provider error (${detail}); reporting without a strike`);
11755
- void table2.finishAndCleanup(run3, "failed", `provider error: ${detail}`, {
11756
- judgment: "interrupted"
11757
- });
11758
- } else {
11759
- void table2.finishAndCleanup(
11760
- run3,
11761
- "failed",
11762
- signal ? `harness killed by ${signal}` : `harness exited with code ${code}`
11763
- );
11764
- }
11765
- }
12272
+ const { status, error, note, ...judgment } = classifyExit({
12273
+ harness: opts.harness,
12274
+ code,
12275
+ signal,
12276
+ timedOut: run3.timedOut,
12277
+ timeoutMinutes: assignment.timeout_minutes,
12278
+ limited: run3.limiter?.signal() ?? null,
12279
+ providerError: run3.limiter?.providerError() ?? null,
12280
+ // pi: its stream, not its exit code, says how the run ended.
12281
+ ...run3.renderer ? { harnessOutcome: run3.renderer.summary().harnessOutcome } : {},
12282
+ effortMismatch: run3.effortMismatch ?? null,
12283
+ secrets: secretEnvValues
12284
+ });
12285
+ if (note) log(`run ${runId}: ${note}; reporting without a strike`);
12286
+ void table2.finishAndCleanup(run3, status, error, judgment.judgment ? judgment : void 0);
11766
12287
  });
11767
12288
  } catch (err) {
11768
12289
  void table2.finishAndCleanup(
@@ -11929,7 +12450,7 @@ function withDaemonFlags(cmd) {
11929
12450
  return cmd.option(
11930
12451
  "--name <name>",
11931
12452
  "runner name, unique per user; name it machine-plus-harness, e.g. macbook-claude (default: this hostname)"
11932
- ).option("--harness <harness>", "claude-code | codex | custom", "claude-code").option(
12453
+ ).option("--harness <harness>", "claude-code | codex | pi | custom", "claude-code").option(
11933
12454
  "--command <template>",
11934
12455
  "custom harness command template ({prompt_file}, {workspace}, {model}, {effort})"
11935
12456
  ).option(
@@ -11959,7 +12480,7 @@ function withDaemonFlags(cmd) {
11959
12480
  function parseDaemonFlags(opts) {
11960
12481
  const harness = opts.harness.replaceAll("-", "_");
11961
12482
  if (!HARNESS_KINDS.includes(harness)) {
11962
- die(`--harness must be claude-code, codex, or custom, got "${opts.harness}"`);
12483
+ die(`--harness must be claude-code, codex, pi, or custom, got "${opts.harness}"`);
11963
12484
  }
11964
12485
  if (harness === "custom" && !opts.command) {
11965
12486
  die(
@@ -12010,7 +12531,7 @@ import { promisify } from "node:util";
12010
12531
 
12011
12532
  // src/daemon/service.ts
12012
12533
  import { existsSync as existsSync7, statSync as statSync5 } from "node:fs";
12013
- import { delimiter as delimiter2, dirname as dirname5, join as join8, sep as sep4 } from "node:path";
12534
+ import { delimiter as delimiter2, dirname as dirname5, join as join9, sep as sep4 } from "node:path";
12014
12535
  function defaultServiceManager(platform2 = process.platform) {
12015
12536
  if (platform2 === "darwin") return "launchd";
12016
12537
  if (platform2 === "linux") return "systemd";
@@ -12021,13 +12542,13 @@ function serviceLabel(name2, kind) {
12021
12542
  }
12022
12543
  function unitPath(name2, kind, home) {
12023
12544
  const label = serviceLabel(name2, kind);
12024
- return kind === "launchd" ? join8(home, "Library", "LaunchAgents", `${label}.plist`) : join8(home, ".config", "systemd", "user", `${label}.service`);
12545
+ return kind === "launchd" ? join9(home, "Library", "LaunchAgents", `${label}.plist`) : join9(home, ".config", "systemd", "user", `${label}.service`);
12025
12546
  }
12026
12547
  function serviceLogPath(configDir, name2) {
12027
- return join8(configDir, "logs", `runner-${name2}.log`);
12548
+ return join9(configDir, "logs", `runner-${name2}.log`);
12028
12549
  }
12029
12550
  function managedBinary(prefix) {
12030
- return join8(prefix, "node_modules", ".bin", "tines");
12551
+ return join9(prefix, "node_modules", ".bin", "tines");
12031
12552
  }
12032
12553
  function daemonArgs(input2) {
12033
12554
  const args = [
@@ -12056,7 +12577,7 @@ function daemonArgs(input2) {
12056
12577
  function findOnPath(name2, envPath) {
12057
12578
  for (const dir of (envPath ?? "").split(delimiter2)) {
12058
12579
  if (!dir) continue;
12059
- const candidate = join8(dir, name2);
12580
+ const candidate = join9(dir, name2);
12060
12581
  try {
12061
12582
  if (statSync5(candidate).isFile()) return candidate;
12062
12583
  } catch {
@@ -12077,6 +12598,11 @@ function servicePath(input2) {
12077
12598
  );
12078
12599
  return { path: [...new Set(dirs)].join(delimiter2), missing };
12079
12600
  }
12601
+ function listedPiModels(stdout) {
12602
+ const lines2 = stdout.split("\n").filter((line) => line.trim() !== "");
12603
+ if (lines2.length === 0 || !/^provider\s+model\b/.test(lines2[0])) return 0;
12604
+ return lines2.length - 1;
12605
+ }
12080
12606
  function xml(value) {
12081
12607
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
12082
12608
  }
@@ -12205,6 +12731,12 @@ function managedBinaryPresent(prefix) {
12205
12731
  var run2 = promisify(execFile2);
12206
12732
  var STARTUP_TIMEOUT_MS = 3e4;
12207
12733
  var log2 = (message3) => console.log(message3);
12734
+ var HARNESS_BINARIES = {
12735
+ claude_code: "claude",
12736
+ codex: "codex",
12737
+ pi: "pi",
12738
+ custom: null
12739
+ };
12208
12740
  function resolveServiceManager(flag) {
12209
12741
  if (flag !== void 0) {
12210
12742
  if (flag !== "launchd" && flag !== "systemd")
@@ -12257,6 +12789,44 @@ async function waitForStart(logPath, offset, name2) {
12257
12789
  }
12258
12790
  return false;
12259
12791
  }
12792
+ async function requirePiVersion(path2) {
12793
+ const pi = findOnPath("pi", path2);
12794
+ if (!pi) return false;
12795
+ let version;
12796
+ try {
12797
+ version = (await run2(pi, ["--version"], { timeout: 1e4 })).stdout.trim();
12798
+ } catch (err) {
12799
+ die(`\`${pi} --version\` failed: ${err instanceof Error ? err.message : String(err)}`);
12800
+ }
12801
+ if (!piVersionSupported(version)) {
12802
+ die(
12803
+ `${pi} is pi ${version || "(unknown version)"}, older than ${PI_VERSION_FLOOR}, the oldest the pi harness supports \u2014 update it (\`npm install -g @earendil-works/pi-coding-agent\`), then install again`
12804
+ );
12805
+ }
12806
+ return true;
12807
+ }
12808
+ async function warnIfPiListsNoModels(path2) {
12809
+ let count;
12810
+ try {
12811
+ const { stdout } = await run2("pi", ["--list-models"], {
12812
+ env: { PATH: path2, HOME: homedir3() },
12813
+ timeout: 3e4
12814
+ });
12815
+ count = listedPiModels(stdout);
12816
+ } catch (err) {
12817
+ log2(
12818
+ `warning: could not run \`pi --list-models\` with the service's PATH (${err instanceof Error ? err.message.split("\n")[0] : String(err)}); check the model list yourself before routing work here`
12819
+ );
12820
+ return;
12821
+ }
12822
+ if (count > 0) {
12823
+ log2(`pi lists ${count} model${count === 1 ? "" : "s"} in the service's environment`);
12824
+ return;
12825
+ }
12826
+ log2(
12827
+ 'warning: `pi --list-models` lists no models in the service\'s environment. The service gets only PATH and HOME, not this shell\'s variables, so model credentials that depend on one (an API key exported in your shell profile) are invisible to it and every run would fail. Put the credential where pi reads it without the shell (`~/.pi/agent/auth.json`, or a literal key in `~/.pi/agent/models.json`), then `tines runner restart`. See docs/runner-daemon.md, "Pi" \u2192 "Model credentials".'
12828
+ );
12829
+ }
12260
12830
  function serviceTarget(name2, kind) {
12261
12831
  return { kind, name: name2, unitPath: unitPath(name2, kind, homedir3()), uid: userInfo().uid };
12262
12832
  }
@@ -12292,6 +12862,14 @@ ${lines2.join("\n")}
12292
12862
  stop it first \u2014 \`tines runners pause ${settings.name}\`, wait for 0 active runs, kill that process \u2014 then run install again; it reconnects with the stored token, so no key is needed`
12293
12863
  );
12294
12864
  }
12865
+ const harnessBinary = HARNESS_BINARIES[settings.harness];
12866
+ const { path: path2, missing } = servicePath({
12867
+ execPath: process.execPath,
12868
+ required: [...harnessBinary ? [harnessBinary] : [], "git"],
12869
+ envPath: process.env.PATH,
12870
+ platform: process.platform
12871
+ });
12872
+ const piPresent = settings.harness === "pi" && await requirePiVersion(path2);
12295
12873
  if (!managedBinaryPresent(prefix)) {
12296
12874
  log2(`installing the daemon-managed tines into ${prefix} \u2026`);
12297
12875
  await installAgentCli({ configDir, log: log2 });
@@ -12317,18 +12895,12 @@ stop it first \u2014 \`tines runners pause ${settings.name}\`, wait for 0 active
12317
12895
  } catch (err) {
12318
12896
  die(err instanceof Error ? err.message : String(err));
12319
12897
  }
12320
- const harnessBinary = settings.harness === "claude_code" ? "claude" : settings.harness === "codex" ? "codex" : null;
12321
- const { path: path2, missing } = servicePath({
12322
- execPath: process.execPath,
12323
- required: [...harnessBinary ? [harnessBinary] : [], "git"],
12324
- envPath: process.env.PATH,
12325
- platform: process.platform
12326
- });
12327
12898
  for (const name2 of missing) {
12328
12899
  log2(
12329
12900
  `warning: \`${name2}\` is not on this shell's PATH, so it is not on the service's either \u2014 install it, then \`tines runner install\` again`
12330
12901
  );
12331
12902
  }
12903
+ if (piPresent) await warnIfPiListsNoModels(path2);
12332
12904
  const target = serviceTarget(settings.name, kind);
12333
12905
  const logPath = serviceLogPath(configDir, settings.name);
12334
12906
  const spec = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tines",
3
- "version": "0.0.315",
3
+ "version": "0.0.316",
4
4
  "description": "CLI for Tines, an orchestration layer for AI agents",
5
5
  "repository": {
6
6
  "type": "git",