@cabane/companion 0.6.156 → 0.6.158

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/cli.js CHANGED
@@ -692,16 +692,16 @@ function migrateConnectedHarnesses(cfg) {
692
692
  }
693
693
  function localAgentConfig(cfg, agent) {
694
694
  const map = cfg.agents ?? {};
695
- const row2 = map[agent.agentId] ?? map[agent.agentUsername] ?? map[`${agent.workspaceSlug}/${agent.agentUsername}`] ?? {};
696
- if (!cfg.prepareHook || row2.prepareHook) return row2;
697
- return { ...row2, prepareHook: cfg.prepareHook };
695
+ const row3 = map[agent.agentId] ?? map[agent.agentUsername] ?? map[`${agent.workspaceSlug}/${agent.agentUsername}`] ?? {};
696
+ if (!cfg.prepareHook || row3.prepareHook) return row3;
697
+ return { ...row3, prepareHook: cfg.prepareHook };
698
698
  }
699
699
  function agentSandbox(cfg, agent) {
700
700
  const sandbox = cfg.agents?.[agent.agentId]?.sandbox;
701
701
  return sandbox ? { sandbox } : {};
702
702
  }
703
- function runnerSandboxFor(cfg, row2, host) {
704
- const asked = row2.sandbox ?? cfg.runners?.sandbox ?? "none";
703
+ function runnerSandboxFor(cfg, row3, host) {
704
+ const asked = row3.sandbox ?? cfg.runners?.sandbox ?? "none";
705
705
  if (asked !== "os" || cfg.runners?.provider === "docker") return "none";
706
706
  return host.osSandboxUsable() ? "os" : "none";
707
707
  }
@@ -715,7 +715,7 @@ function runnerSandboxReport(cfg, agents, host) {
715
715
  }
716
716
  function agentSandboxRows(cfg) {
717
717
  return Object.entries(cfg.agents ?? {}).flatMap(
718
- ([key, row2]) => row2.sandbox ? [[key, row2.sandbox]] : []
718
+ ([key, row3]) => row3.sandbox ? [[key, row3.sandbox]] : []
719
719
  );
720
720
  }
721
721
  function loadConfig() {
@@ -897,8 +897,8 @@ var init_config = __esm({
897
897
  }).strict();
898
898
  AGENT_ID_KEY = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
899
899
  agentsConfigSchema = z2.record(z2.string(), localAgentConfigSchema).superRefine((agents, ctx2) => {
900
- for (const [key, row2] of Object.entries(agents)) {
901
- if (row2.sandbox === void 0) continue;
900
+ for (const [key, row3] of Object.entries(agents)) {
901
+ if (row3.sandbox === void 0) continue;
902
902
  if (AGENT_ID_KEY.test(key)) continue;
903
903
  ctx2.addIssue({
904
904
  code: z2.ZodIssueCode.custom,
@@ -965,6 +965,15 @@ var init_config = __esm({
965
965
  // absent `claudeCode` block means "never offered"; without it, the block's
966
966
  // absence marks a config from before connectors were chosen.
967
967
  harnessesChosen: z2.boolean().optional(),
968
+ // Whether this companion keeps itself current: installs the version Cabane names, between
969
+ // turns, and rolls back one that doesn't come up (`updater.ts`). Absent means never asked,
970
+ // which `start` offers; off either way until someone says yes.
971
+ update: z2.object({
972
+ auto: z2.boolean()
973
+ }).strict().optional(),
974
+ // The answer to `start`'s "start at boot?" when it was no, so it isn't asked again.
975
+ // `cabane service install` still installs it.
976
+ serviceDeclined: z2.boolean().optional(),
968
977
  // OpenCode's decline. Its block is an address, so a decline can't live there
969
978
  // the way `claudeCode`/`codex` `enabled: false` does; without it a declined
970
979
  // serve would be offered again on every `start`.
@@ -2682,8 +2691,8 @@ var init_policy = __esm({
2682
2691
  // packages/agent-runtime/src/claude-code/model.ts
2683
2692
  function parseClaudeCodeModel(model) {
2684
2693
  if (!model) return null;
2685
- const sep5 = model.indexOf("/");
2686
- const bare = sep5 === -1 || model.slice(0, sep5) !== "anthropic" ? model : model.slice(sep5 + 1);
2694
+ const sep6 = model.indexOf("/");
2695
+ const bare = sep6 === -1 || model.slice(0, sep6) !== "anthropic" ? model : model.slice(sep6 + 1);
2687
2696
  return bare === CLAUDE_CODE_DEFAULT_MODEL_BARE ? null : model;
2688
2697
  }
2689
2698
  var CLAUDE_CODE_DEFAULT_MODEL_BARE;
@@ -2887,6 +2896,208 @@ var init_options = __esm({
2887
2896
  }
2888
2897
  });
2889
2898
 
2899
+ // packages/agent-runtime/src/claude-code/failure-evidence.ts
2900
+ function createFailureEvidence() {
2901
+ const signals = { sawRejectedLimit: false };
2902
+ return {
2903
+ observe(msg) {
2904
+ if (msg.type === "assistant" || msg.type === "auth_status") {
2905
+ const error = msg.error;
2906
+ if (typeof error === "string" && error.length > 0) {
2907
+ if (msg.type === "assistant") signals.lastAssistantError = error;
2908
+ else signals.authError = error;
2909
+ }
2910
+ } else if (msg.type === "rate_limit_event") {
2911
+ const info = msg.rate_limit_info;
2912
+ if (info?.status === "rejected") {
2913
+ signals.sawRejectedLimit = true;
2914
+ signals.rateLimitResetIso = resetsAtToIso(info.resetsAt) ?? signals.rateLimitResetIso;
2915
+ if (typeof info.rateLimitType === "string") signals.rateLimitType = info.rateLimitType;
2916
+ }
2917
+ }
2918
+ },
2919
+ preInitReason(msg) {
2920
+ const failure = msg ? classifyPreInitFrame(msg, signals) : classifyFailure(signals, { text: "" });
2921
+ return failure ? encodeFailureReason(failure) : "session_start_failed";
2922
+ },
2923
+ failedResultReason(msg) {
2924
+ const failure = classifyResultFailure(msg, signals);
2925
+ const resultText = msg.result ?? "";
2926
+ const isError = msg.is_error === true;
2927
+ return failure ? encodeFailureReason(failure) : isError ? `error:${resultText.slice(0, 200) || "unknown"}` : `result_error:${msg.subtype}`;
2928
+ },
2929
+ thrownFailure(message2, initReceived) {
2930
+ return initReceived ? classifyErrorText(message2) : classifyFailure(signals, { text: message2 });
2931
+ }
2932
+ };
2933
+ }
2934
+ function classifyFailure(s, frame) {
2935
+ const rejectedIsCap = s.sawRejectedLimit && (s.rateLimitResetIso !== void 0 || isSubscriptionWindow(s.rateLimitType));
2936
+ if (rejectedIsCap || frame.terminalReason === "blocking_limit")
2937
+ return {
2938
+ kind: "usage_capped",
2939
+ ...s.rateLimitResetIso ? { resetsAt: s.rateLimitResetIso } : {}
2940
+ };
2941
+ return classifyAssistantError(s.lastAssistantError) ?? classifyErrorText([s.authError, frame.text].filter(Boolean).join(" "));
2942
+ }
2943
+ function classifyResultFailure(msg, s) {
2944
+ const resultText = msg.result ?? "";
2945
+ const terminalReason = msg.terminal_reason;
2946
+ const errors = msg.errors;
2947
+ const text = [resultText, ...Array.isArray(errors) ? errors.map(String) : []].join(" ");
2948
+ return classifyFailure(s, { terminalReason, text });
2949
+ }
2950
+ function classifyPreInitFrame(msg, s) {
2951
+ if (msg.type === "result") {
2952
+ const isError = msg.is_error === true;
2953
+ if (msg.subtype === "success" && !isError) return classifyFailure(s, { text: "" });
2954
+ return classifyResultFailure(msg, s);
2955
+ }
2956
+ if (msg.type !== "assistant") return classifyFailure(s, { text: "" });
2957
+ const text = contentBlocks(msg).flatMap((b) => b.type === "text" && typeof b.text === "string" ? [b.text] : []).join(" ");
2958
+ return classifyFailure(s, { text });
2959
+ }
2960
+ function preInitToolActivity(msg) {
2961
+ const kind = msg.type === "assistant" ? "tool_use" : msg.type === "user" ? "tool_result" : null;
2962
+ return kind !== null && contentBlocks(msg).some((b) => b.type === kind);
2963
+ }
2964
+ function contentBlocks(msg) {
2965
+ const content = msg.message?.content;
2966
+ return Array.isArray(content) ? content : [];
2967
+ }
2968
+ function isSubscriptionWindow(value) {
2969
+ return value === "five_hour" || value === "seven_day" || value === "seven_day_opus" || value === "seven_day_sonnet" || value === "overage";
2970
+ }
2971
+ var init_failure_evidence = __esm({
2972
+ "packages/agent-runtime/src/claude-code/failure-evidence.ts"() {
2973
+ "use strict";
2974
+ init_failure();
2975
+ }
2976
+ });
2977
+
2978
+ // packages/agent-runtime/src/claude-code/context-usage.ts
2979
+ function createContextUsage() {
2980
+ let resolvedModel;
2981
+ let lastRequestContextTokens;
2982
+ const compactions = [];
2983
+ return {
2984
+ get resolvedModel() {
2985
+ return resolvedModel;
2986
+ },
2987
+ recordInit(msg) {
2988
+ const model = msg.model;
2989
+ if (typeof model === "string" && model.length > 0) resolvedModel = model;
2990
+ },
2991
+ recordCompaction(msg) {
2992
+ const compaction = readCompaction(msg);
2993
+ if (compaction) compactions.push(compaction);
2994
+ },
2995
+ recordRequest(msg) {
2996
+ const tokens = readRequestContextTokens(msg);
2997
+ if (tokens !== void 0) lastRequestContextTokens = tokens;
2998
+ },
2999
+ async resultUsage(msg, readContextUsage) {
3000
+ const usage = readSdkUsage(msg.usage);
3001
+ if (usage) {
3002
+ if (lastRequestContextTokens !== void 0) usage.contextTokens = lastRequestContextTokens;
3003
+ const window = readContextWindow(msg, resolvedModel);
3004
+ if (window !== void 0) usage.contextWindow = window;
3005
+ if (compactions.length > 0) usage.contextCompactions = compactions;
3006
+ const reading = await readHarnessContext(readContextUsage);
3007
+ if (reading) Object.assign(usage, reading);
3008
+ }
3009
+ return usage;
3010
+ }
3011
+ };
3012
+ }
3013
+ function readSdkUsage(usage) {
3014
+ if (!usage || typeof usage !== "object") return void 0;
3015
+ const fields = usage;
3016
+ const num = (v) => typeof v === "number" && Number.isFinite(v) ? v : 0;
3017
+ const cacheReadTokens = num(fields.cache_read_input_tokens);
3018
+ const cacheCreationTokens = num(fields.cache_creation_input_tokens);
3019
+ const inputTokens = num(fields.input_tokens) + cacheCreationTokens + cacheReadTokens;
3020
+ const outputTokens = num(fields.output_tokens);
3021
+ return { inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens };
3022
+ }
3023
+ function readRequestContextTokens(msg) {
3024
+ return readSdkUsage(msg.message?.usage)?.inputTokens;
3025
+ }
3026
+ async function readHarnessContext(read) {
3027
+ if (!read) return void 0;
3028
+ let timer;
3029
+ const timeout = new Promise((resolve2) => {
3030
+ timer = setTimeout(() => resolve2(void 0), CONTEXT_READING_TIMEOUT_MS);
3031
+ });
3032
+ try {
3033
+ return parseContextUsage(await Promise.race([read(), timeout]));
3034
+ } catch {
3035
+ return void 0;
3036
+ } finally {
3037
+ clearTimeout(timer);
3038
+ }
3039
+ }
3040
+ function parseContextUsage(response) {
3041
+ const r = response;
3042
+ if (!r || typeof r !== "object") return void 0;
3043
+ const total = readContextCount(r.totalTokens);
3044
+ const window = readContextCount(r.maxTokens);
3045
+ if (total === void 0 || window === void 0 || window === 0) return void 0;
3046
+ const categories = [];
3047
+ for (const c of Array.isArray(r.categories) ? r.categories : []) {
3048
+ const row3 = c;
3049
+ const tokens = readContextCount(row3?.tokens);
3050
+ if (typeof row3?.name === "string" && tokens !== void 0 && typeof row3.kind === "string" && CATEGORY_KINDS.has(row3.kind)) {
3051
+ categories.push({ name: row3.name, tokens, kind: row3.kind });
3052
+ }
3053
+ }
3054
+ const buffer = categories.filter((c) => c.kind === "buffer").reduce((sum, c) => sum + c.tokens, 0);
3055
+ return {
3056
+ contextTokens: total,
3057
+ contextWindow: window,
3058
+ ...buffer > 0 && buffer < window ? { contextCompactAt: window - buffer } : {},
3059
+ ...categories.length > 0 ? { contextCategories: categories } : {}
3060
+ };
3061
+ }
3062
+ function readCompaction(msg) {
3063
+ const meta = msg.compact_metadata;
3064
+ const preTokens = readContextCount(meta?.pre_tokens);
3065
+ if (!meta || preTokens === void 0) return void 0;
3066
+ if (meta.trigger !== "manual" && meta.trigger !== "auto") return void 0;
3067
+ const postTokens = readContextCount(meta.post_tokens);
3068
+ return {
3069
+ trigger: meta.trigger,
3070
+ preTokens,
3071
+ ...postTokens !== void 0 ? { postTokens } : {}
3072
+ };
3073
+ }
3074
+ function readContextWindow(msg, resolvedModel) {
3075
+ const modelUsage = msg.modelUsage;
3076
+ if (!modelUsage || typeof modelUsage !== "object") return void 0;
3077
+ const pos = (v) => typeof v === "number" && Number.isFinite(v) && v > 0 ? v : void 0;
3078
+ if (resolvedModel) {
3079
+ const direct = pos(modelUsage[resolvedModel]?.contextWindow);
3080
+ if (direct !== void 0) return direct;
3081
+ }
3082
+ let max;
3083
+ for (const entry of Object.values(modelUsage)) {
3084
+ const w = pos(entry?.contextWindow);
3085
+ if (w !== void 0 && (max === void 0 || w > max)) max = w;
3086
+ }
3087
+ return max;
3088
+ }
3089
+ function readContextCount(v) {
3090
+ return typeof v === "number" && Number.isFinite(v) && v >= 0 ? Math.round(v) : void 0;
3091
+ }
3092
+ var CONTEXT_READING_TIMEOUT_MS, CATEGORY_KINDS;
3093
+ var init_context_usage = __esm({
3094
+ "packages/agent-runtime/src/claude-code/context-usage.ts"() {
3095
+ "use strict";
3096
+ CONTEXT_READING_TIMEOUT_MS = 2e3;
3097
+ CATEGORY_KINDS = /* @__PURE__ */ new Set(["used", "free", "buffer", "deferred"]);
3098
+ }
3099
+ });
3100
+
2890
3101
  // packages/agent-runtime/src/claude-code/loop.ts
2891
3102
  async function* decodeSdkStream(iter, ctx2) {
2892
3103
  const pending = /* @__PURE__ */ new Map();
@@ -2902,17 +3113,15 @@ async function* decodeSdkStream(iter, ctx2) {
2902
3113
  let resultReason;
2903
3114
  let sawResult = false;
2904
3115
  let usage;
2905
- const compactions = [];
2906
- let lastRequestContextTokens;
2907
- let resolvedModel;
2908
- const signals = { sawRejectedLimit: false };
3116
+ const contextUsage = createContextUsage();
3117
+ const failureEvidence = createFailureEvidence();
2909
3118
  let preInitNotice = null;
2910
3119
  try {
2911
3120
  for await (const msg of iter) {
2912
3121
  if (ctx2.signal.aborted) return;
3122
+ failureEvidence.observe(msg);
2913
3123
  if (msg.type === "system" && msg.subtype === "init") {
2914
- const initModel = msg.model;
2915
- if (typeof initModel === "string" && initModel.length > 0) resolvedModel = initModel;
3124
+ contextUsage.recordInit(msg);
2916
3125
  mcpInventory = readMcpInventory(msg);
2917
3126
  workspaceProven = provesWorkspaceTools(msg);
2918
3127
  if (!workspaceProven) {
@@ -2929,63 +3138,35 @@ async function* decodeSdkStream(iter, ctx2) {
2929
3138
  };
2930
3139
  }
2931
3140
  } else if (msg.type === "system" && msg.subtype === "compact_boundary") {
2932
- const compaction = readCompaction(msg);
2933
- if (compaction) compactions.push(compaction);
3141
+ contextUsage.recordCompaction(msg);
2934
3142
  } else if (!workspaceProven && (msg.type === "assistant" || msg.type === "user" || msg.type === "result")) {
2935
3143
  if (preInitToolActivity(msg) && ctx2.startEvidence) ctx2.startEvidence.toolActivity = true;
2936
- const failure = classifyPreInitFrame(msg, signals);
2937
- resultReason = failure ? encodeFailureReason(failure) : "session_start_failed";
3144
+ resultReason = failureEvidence.preInitReason(msg);
2938
3145
  if (msg.type === "assistant" && isHarnessAuthored(msg))
2939
3146
  preInitNotice = harnessNoticeText(msg);
2940
3147
  break;
2941
3148
  } else if (msg.type === "assistant") {
2942
- const assistantErr = msg.error;
2943
- if (typeof assistantErr === "string" && assistantErr.length > 0) {
2944
- signals.lastAssistantError = assistantErr;
2945
- }
2946
3149
  if (isHarnessAuthored(msg)) {
2947
3150
  await emitHarnessMessage(msg, emit, pending, buffer);
2948
3151
  yield* drain(out);
2949
3152
  continue;
2950
3153
  }
2951
- const reqContext = readRequestContextTokens(msg);
2952
- if (reqContext !== void 0) lastRequestContextTokens = reqContext;
3154
+ contextUsage.recordRequest(msg);
2953
3155
  await processAssistantMessage(msg, emit, pending, buffer);
2954
3156
  yield* drain(out);
2955
3157
  } else if (msg.type === "user") {
2956
3158
  await emitToolResults(msg, emit, pending);
2957
3159
  yield* drain(out);
2958
- } else if (msg.type === "rate_limit_event") {
2959
- const info = msg.rate_limit_info;
2960
- if (info?.status === "rejected") {
2961
- signals.sawRejectedLimit = true;
2962
- signals.rateLimitResetIso = resetsAtToIso(info.resetsAt) ?? signals.rateLimitResetIso;
2963
- if (typeof info.rateLimitType === "string") signals.rateLimitType = info.rateLimitType;
2964
- }
2965
- } else if (msg.type === "auth_status") {
2966
- const err = msg.error;
2967
- if (typeof err === "string" && err.length > 0) signals.authError = err;
2968
3160
  } else if (msg.type === "result") {
2969
3161
  const origin = msg.origin;
2970
3162
  if (origin?.kind === "task-notification") continue;
2971
3163
  sawResult = true;
2972
- usage = readSdkUsage(msg);
2973
- if (usage) {
2974
- if (lastRequestContextTokens !== void 0)
2975
- usage.contextTokens = lastRequestContextTokens;
2976
- const window = readContextWindow(msg, resolvedModel);
2977
- if (window !== void 0) usage.contextWindow = window;
2978
- if (compactions.length > 0) usage.contextCompactions = compactions;
2979
- const reading = await readHarnessContext(ctx2.readContextUsage);
2980
- if (reading) Object.assign(usage, reading);
2981
- }
3164
+ usage = await contextUsage.resultUsage(msg, ctx2.readContextUsage);
2982
3165
  const isError = msg.is_error === true;
2983
3166
  if (msg.subtype === "success" && !isError) {
2984
3167
  ok = true;
2985
3168
  } else {
2986
- const resultText = msg.result ?? "";
2987
- const failure = classifyResultFailure(msg, signals);
2988
- resultReason = failure ? encodeFailureReason(failure) : isError ? `error:${resultText.slice(0, 200) || "unknown"}` : `result_error:${msg.subtype}`;
3169
+ resultReason = failureEvidence.failedResultReason(msg);
2989
3170
  ok = false;
2990
3171
  }
2991
3172
  break;
@@ -2994,7 +3175,7 @@ async function* decodeSdkStream(iter, ctx2) {
2994
3175
  } catch (err) {
2995
3176
  if (ctx2.signal.aborted) throw err;
2996
3177
  const message2 = err instanceof Error ? err.message : String(err);
2997
- const failure = mcpInventory.initReceived ? classifyErrorText(message2) : classifyFailure(signals, { text: message2 });
3178
+ const failure = failureEvidence.thrownFailure(message2, mcpInventory.initReceived);
2998
3179
  if (!failure && (mcpInventory.initReceived || ctx2.resumedSessionId === null)) throw err;
2999
3180
  ok = false;
3000
3181
  if (failure) {
@@ -3009,8 +3190,7 @@ async function* decodeSdkStream(iter, ctx2) {
3009
3190
  if (!workspaceProven) {
3010
3191
  ok = false;
3011
3192
  if (resultReason === void 0) {
3012
- const failure = classifyFailure(signals, { text: "" });
3013
- resultReason = failure ? encodeFailureReason(failure) : "session_start_failed";
3193
+ resultReason = failureEvidence.preInitReason();
3014
3194
  }
3015
3195
  if (preInitNotice) yield { type: "runtime_notice", body: preInitNotice };
3016
3196
  }
@@ -3028,7 +3208,7 @@ async function* decodeSdkStream(iter, ctx2) {
3028
3208
  ok,
3029
3209
  ...resultReason ? { reason: resultReason } : {},
3030
3210
  ...usage ? { usage } : {},
3031
- ...resolvedModel ? { resolvedModel } : {},
3211
+ ...contextUsage.resolvedModel ? { resolvedModel: contextUsage.resolvedModel } : {},
3032
3212
  mcpInventory
3033
3213
  };
3034
3214
  }
@@ -3047,38 +3227,6 @@ function provesWorkspaceTools(msg) {
3047
3227
  const tools = msg.tools;
3048
3228
  return Array.isArray(tools) && tools.includes("mcp__cabane__query");
3049
3229
  }
3050
- function classifyFailure(s, frame) {
3051
- const rejectedIsCap = s.sawRejectedLimit && (s.rateLimitResetIso !== void 0 || isSubscriptionWindow(s.rateLimitType));
3052
- if (rejectedIsCap || frame.terminalReason === "blocking_limit")
3053
- return {
3054
- kind: "usage_capped",
3055
- ...s.rateLimitResetIso ? { resetsAt: s.rateLimitResetIso } : {}
3056
- };
3057
- return classifyAssistantError(s.lastAssistantError) ?? classifyErrorText([s.authError, frame.text].filter(Boolean).join(" "));
3058
- }
3059
- function classifyResultFailure(msg, s) {
3060
- const resultText = msg.result ?? "";
3061
- const terminalReason = msg.terminal_reason;
3062
- const errors = msg.errors;
3063
- const text = [resultText, ...Array.isArray(errors) ? errors.map(String) : []].join(" ");
3064
- return classifyFailure(s, { terminalReason, text });
3065
- }
3066
- function classifyPreInitFrame(msg, s) {
3067
- if (msg.type === "result") {
3068
- const isError = msg.is_error === true;
3069
- if (msg.subtype === "success" && !isError) return classifyFailure(s, { text: "" });
3070
- return classifyResultFailure(msg, s);
3071
- }
3072
- if (msg.type !== "assistant") return classifyFailure(s, { text: "" });
3073
- const error = msg.error;
3074
- const signals = typeof error === "string" && error.length > 0 ? { ...s, lastAssistantError: error } : s;
3075
- const text = contentBlocks(msg).flatMap((b) => b.type === "text" && typeof b.text === "string" ? [b.text] : []).join(" ");
3076
- return classifyFailure(signals, { text });
3077
- }
3078
- function preInitToolActivity(msg) {
3079
- const kind = msg.type === "assistant" ? "tool_use" : msg.type === "user" ? "tool_result" : null;
3080
- return kind !== null && contentBlocks(msg).some((b) => b.type === kind);
3081
- }
3082
3230
  function isHarnessAuthored(msg) {
3083
3231
  const error = msg.error;
3084
3232
  if (typeof error === "string" && error.length > 0) return true;
@@ -3102,107 +3250,19 @@ async function emitHarnessMessage(msg, emit, pending, buffer) {
3102
3250
  );
3103
3251
  }
3104
3252
  }
3105
- function contentBlocks(msg) {
3106
- const content = msg.message?.content;
3107
- return Array.isArray(content) ? content : [];
3108
- }
3109
- function isSubscriptionWindow(value) {
3110
- return value === "five_hour" || value === "seven_day" || value === "seven_day_opus" || value === "seven_day_sonnet" || value === "overage";
3111
- }
3112
3253
  function* drain(out) {
3113
3254
  while (out.length > 0) yield out.shift();
3114
3255
  }
3115
- function readSdkUsage(msg) {
3116
- const usage = msg.usage;
3117
- if (!usage || typeof usage !== "object") return void 0;
3118
- const num = (v) => typeof v === "number" && Number.isFinite(v) ? v : 0;
3119
- const cacheReadTokens = num(usage.cache_read_input_tokens);
3120
- const cacheCreationTokens = num(usage.cache_creation_input_tokens);
3121
- const inputTokens = num(usage.input_tokens) + cacheCreationTokens + cacheReadTokens;
3122
- const outputTokens = num(usage.output_tokens);
3123
- return { inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens };
3124
- }
3125
- function readRequestContextTokens(msg) {
3126
- const usage = msg.message?.usage;
3127
- if (!usage || typeof usage !== "object") return void 0;
3128
- const num = (v) => typeof v === "number" && Number.isFinite(v) ? v : 0;
3129
- return num(usage.input_tokens) + num(usage.cache_read_input_tokens) + num(usage.cache_creation_input_tokens);
3130
- }
3131
- async function readHarnessContext(read) {
3132
- if (!read) return void 0;
3133
- let timer;
3134
- const timeout = new Promise((resolve2) => {
3135
- timer = setTimeout(() => resolve2(void 0), CONTEXT_READING_TIMEOUT_MS);
3136
- });
3137
- try {
3138
- return parseContextUsage(await Promise.race([read(), timeout]));
3139
- } catch {
3140
- return void 0;
3141
- } finally {
3142
- clearTimeout(timer);
3143
- }
3144
- }
3145
- function parseContextUsage(response) {
3146
- const r = response;
3147
- if (!r || typeof r !== "object") return void 0;
3148
- const count = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0 ? Math.round(v) : void 0;
3149
- const total = count(r.totalTokens);
3150
- const window = count(r.maxTokens);
3151
- if (total === void 0 || window === void 0 || window === 0) return void 0;
3152
- const categories = [];
3153
- for (const c of Array.isArray(r.categories) ? r.categories : []) {
3154
- const row2 = c;
3155
- const tokens = count(row2?.tokens);
3156
- if (typeof row2?.name === "string" && tokens !== void 0 && typeof row2.kind === "string" && CATEGORY_KINDS.has(row2.kind)) {
3157
- categories.push({ name: row2.name, tokens, kind: row2.kind });
3158
- }
3159
- }
3160
- const buffer = categories.filter((c) => c.kind === "buffer").reduce((sum, c) => sum + c.tokens, 0);
3161
- return {
3162
- contextTokens: total,
3163
- contextWindow: window,
3164
- ...buffer > 0 && buffer < window ? { contextCompactAt: window - buffer } : {},
3165
- ...categories.length > 0 ? { contextCategories: categories } : {}
3166
- };
3167
- }
3168
- function readCompaction(msg) {
3169
- const meta = msg.compact_metadata;
3170
- const count = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0 ? Math.round(v) : void 0;
3171
- const preTokens = count(meta?.pre_tokens);
3172
- if (!meta || preTokens === void 0) return void 0;
3173
- if (meta.trigger !== "manual" && meta.trigger !== "auto") return void 0;
3174
- const postTokens = count(meta.post_tokens);
3175
- return {
3176
- trigger: meta.trigger,
3177
- preTokens,
3178
- ...postTokens !== void 0 ? { postTokens } : {}
3179
- };
3180
- }
3181
- function readContextWindow(msg, resolvedModel) {
3182
- const modelUsage = msg.modelUsage;
3183
- if (!modelUsage || typeof modelUsage !== "object") return void 0;
3184
- const pos = (v) => typeof v === "number" && Number.isFinite(v) && v > 0 ? v : void 0;
3185
- if (resolvedModel) {
3186
- const direct = pos(modelUsage[resolvedModel]?.contextWindow);
3187
- if (direct !== void 0) return direct;
3188
- }
3189
- let max;
3190
- for (const entry of Object.values(modelUsage)) {
3191
- const w = pos(entry?.contextWindow);
3192
- if (w !== void 0 && (max === void 0 || w > max)) max = w;
3193
- }
3194
- return max;
3195
- }
3196
- var HARNESS_PLACEHOLDERS, CONTEXT_READING_TIMEOUT_MS, CATEGORY_KINDS;
3256
+ var HARNESS_PLACEHOLDERS;
3197
3257
  var init_loop = __esm({
3198
3258
  "packages/agent-runtime/src/claude-code/loop.ts"() {
3199
3259
  "use strict";
3200
3260
  init_transcript();
3201
3261
  init_failure();
3202
3262
  init_session();
3263
+ init_failure_evidence();
3264
+ init_context_usage();
3203
3265
  HARNESS_PLACEHOLDERS = /* @__PURE__ */ new Set(["No response requested.", "(no content)"]);
3204
- CONTEXT_READING_TIMEOUT_MS = 2e3;
3205
- CATEGORY_KINDS = /* @__PURE__ */ new Set(["used", "free", "buffer", "deferred"]);
3206
3266
  }
3207
3267
  });
3208
3268
 
@@ -4236,9 +4296,9 @@ var init_policy2 = __esm({
4236
4296
 
4237
4297
  // packages/agent-runtime/src/opencode/model.ts
4238
4298
  function parseOpencodeModel(model) {
4239
- const sep5 = model.indexOf("/");
4240
- if (sep5 === -1) return { providerID: "", modelID: model };
4241
- return { providerID: model.slice(0, sep5), modelID: model.slice(sep5 + 1) };
4299
+ const sep6 = model.indexOf("/");
4300
+ if (sep6 === -1) return { providerID: "", modelID: model };
4301
+ return { providerID: model.slice(0, sep6), modelID: model.slice(sep6 + 1) };
4242
4302
  }
4243
4303
  var init_model2 = __esm({
4244
4304
  "packages/agent-runtime/src/opencode/model.ts"() {
@@ -4539,10 +4599,10 @@ async function* parseSseStream(body, sessionId, signal) {
4539
4599
  const { value, done } = await reader.read();
4540
4600
  if (done) return;
4541
4601
  buf += decoder.decode(value, { stream: true });
4542
- let sep5;
4543
- while ((sep5 = buf.indexOf("\n\n")) !== -1) {
4544
- const frame = buf.slice(0, sep5);
4545
- buf = buf.slice(sep5 + 2);
4602
+ let sep6;
4603
+ while ((sep6 = buf.indexOf("\n\n")) !== -1) {
4604
+ const frame = buf.slice(0, sep6);
4605
+ buf = buf.slice(sep6 + 2);
4546
4606
  const data = frame.split("\n").filter((l) => l.startsWith("data:")).map((l) => l.slice(5).trim()).join("");
4547
4607
  if (!data) continue;
4548
4608
  let ev;
@@ -5553,8 +5613,8 @@ var init_policy3 = __esm({
5553
5613
 
5554
5614
  // packages/agent-runtime/src/codex/model.ts
5555
5615
  function parseCodexModel(model) {
5556
- const sep5 = model.indexOf("/");
5557
- const bare = sep5 === -1 || model.slice(0, sep5) !== "openai" ? model : model.slice(sep5 + 1);
5616
+ const sep6 = model.indexOf("/");
5617
+ const bare = sep6 === -1 || model.slice(0, sep6) !== "openai" ? model : model.slice(sep6 + 1);
5558
5618
  return CODEX_DEFAULT_MODEL_BARES.has(bare) ? null : bare;
5559
5619
  }
5560
5620
  var CODEX_DEFAULT_MODEL_BARES;
@@ -10249,7 +10309,7 @@ var init_turn_execution = __esm({
10249
10309
 
10250
10310
  // src/dispatcher.ts
10251
10311
  import { randomUUID as randomUUID4 } from "crypto";
10252
- import { existsSync as existsSync11, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
10312
+ import { existsSync as existsSync11, readdirSync as readdirSync2, statSync as statSync3 } from "fs";
10253
10313
  import { join as join17 } from "path";
10254
10314
  function checkoutState(cwd) {
10255
10315
  if (!cwd) return { ok: false, reason: "no working directory was resolved for this turn" };
@@ -10267,7 +10327,7 @@ function checkoutState(cwd) {
10267
10327
  if (!existsSync11(gitPath)) return { ok: true, reason: "usable" };
10268
10328
  let stat;
10269
10329
  try {
10270
- stat = statSync2(gitPath);
10330
+ stat = statSync3(gitPath);
10271
10331
  } catch (error) {
10272
10332
  return { ok: false, reason: `${gitPath} is unreadable (${error.message})` };
10273
10333
  }
@@ -11079,7 +11139,7 @@ import {
11079
11139
  mkdirSync as mkdirSync12,
11080
11140
  readdirSync as readdirSync5,
11081
11141
  readFileSync as readFileSync11,
11082
- renameSync as renameSync6,
11142
+ renameSync as renameSync7,
11083
11143
  rmSync as rmSync11,
11084
11144
  writeFileSync as writeFileSync8
11085
11145
  } from "fs";
@@ -11116,7 +11176,7 @@ var init_outbox = __esm({
11116
11176
  const tmp = `${target}.${process.pid}.tmp`;
11117
11177
  try {
11118
11178
  writeFileSync8(tmp, JSON.stringify(entry) + "\n", "utf8");
11119
- renameSync6(tmp, target);
11179
+ renameSync7(tmp, target);
11120
11180
  } catch (err) {
11121
11181
  try {
11122
11182
  rmSync11(tmp, { force: true });
@@ -11285,8 +11345,8 @@ var init_image_manifest = __esm({
11285
11345
  });
11286
11346
 
11287
11347
  // src/runner/exec-shim.ts
11288
- import { chmodSync as chmodSync7, mkdirSync as mkdirSync15, mkdtempSync as mkdtempSync2, rmSync as rmSync13, writeFileSync as writeFileSync10 } from "fs";
11289
- import { join as join26 } from "path";
11348
+ import { chmodSync as chmodSync8, mkdirSync as mkdirSync20, mkdtempSync as mkdtempSync2, rmSync as rmSync17, writeFileSync as writeFileSync13 } from "fs";
11349
+ import { join as join31 } from "path";
11290
11350
  function shellQuote2(value) {
11291
11351
  return `'${value.replace(/'/g, `'\\''`)}'`;
11292
11352
  }
@@ -11296,18 +11356,18 @@ function createShimDir() {
11296
11356
  return {
11297
11357
  write(argv) {
11298
11358
  if (!dir2) {
11299
- mkdirSync15(join26(cabaneDir(), "runner"), { recursive: true });
11300
- dir2 = mkdtempSync2(join26(cabaneDir(), "runner", "turn-"));
11359
+ mkdirSync20(join31(cabaneDir(), "runner"), { recursive: true });
11360
+ dir2 = mkdtempSync2(join31(cabaneDir(), "runner", "turn-"));
11301
11361
  }
11302
- const path = join26(dir2, `exec-${++count}`);
11303
- writeFileSync10(path, `#!/bin/sh
11362
+ const path = join31(dir2, `exec-${++count}`);
11363
+ writeFileSync13(path, `#!/bin/sh
11304
11364
  exec ${argv.map(shellQuote2).join(" ")} "$@"
11305
11365
  `);
11306
- chmodSync7(path, 448);
11366
+ chmodSync8(path, 448);
11307
11367
  return path;
11308
11368
  },
11309
11369
  dispose() {
11310
- if (dir2) rmSync13(dir2, { recursive: true, force: true });
11370
+ if (dir2) rmSync17(dir2, { recursive: true, force: true });
11311
11371
  dir2 = null;
11312
11372
  }
11313
11373
  };
@@ -12260,7 +12320,11 @@ async function serveConnection(socket, handlers) {
12260
12320
  reply(socket, result);
12261
12321
  return;
12262
12322
  }
12263
- if (req.cmd === "stop") {
12323
+ if (req.cmd === "update") {
12324
+ reply(socket, await handlers.update(req.auto));
12325
+ return;
12326
+ }
12327
+ if (req.cmd === "stop") {
12264
12328
  reply(socket, { ok: true });
12265
12329
  setTimeout(() => handlers.stop(), 50).unref?.();
12266
12330
  return;
@@ -12885,13 +12949,19 @@ init_term();
12885
12949
 
12886
12950
  // src/commands/start.ts
12887
12951
  init_config();
12888
- import { spawn as spawn9 } from "child_process";
12952
+ import { spawn as spawn10 } from "child_process";
12889
12953
  import { hostname as hostname3 } from "os";
12890
12954
  init_cabane_command();
12891
12955
 
12892
12956
  // src/logger.ts
12893
12957
  init_config();
12894
- import { createWriteStream, mkdirSync as mkdirSync5 } from "fs";
12958
+ import {
12959
+ createWriteStream,
12960
+ mkdirSync as mkdirSync5,
12961
+ openSync as openSync2,
12962
+ renameSync as renameSync3,
12963
+ statSync as statSync2
12964
+ } from "fs";
12895
12965
  import { dirname as dirname4, join as join8 } from "path";
12896
12966
  import { stripVTControlCharacters } from "util";
12897
12967
  import pino from "pino";
@@ -12946,6 +13016,53 @@ function formatHumanLine(record) {
12946
13016
  }
12947
13017
  return line + "\n";
12948
13018
  }
13019
+ var LOG_ROTATE_BYTES = 50 * 1024 * 1024;
13020
+ var LOG_ROTATIONS_KEPT = 3;
13021
+ var RotatingLog = class {
13022
+ constructor(path, maxBytes = LOG_ROTATE_BYTES, keep = LOG_ROTATIONS_KEPT) {
13023
+ this.path = path;
13024
+ this.maxBytes = maxBytes;
13025
+ this.keep = keep;
13026
+ this.size = sizeOf(path);
13027
+ this.stream = openAppend(path);
13028
+ }
13029
+ path;
13030
+ maxBytes;
13031
+ keep;
13032
+ stream;
13033
+ size;
13034
+ write(chunk) {
13035
+ const ok = this.stream.write(chunk);
13036
+ this.size += Buffer.byteLength(chunk);
13037
+ if (this.size >= this.maxBytes) this.rotate();
13038
+ return ok;
13039
+ }
13040
+ rotate() {
13041
+ this.stream.end();
13042
+ try {
13043
+ for (let i = this.keep - 1; i >= 1; i--) {
13044
+ try {
13045
+ renameSync3(`${this.path}.${i}`, `${this.path}.${i + 1}`);
13046
+ } catch {
13047
+ }
13048
+ }
13049
+ renameSync3(this.path, `${this.path}.1`);
13050
+ } catch {
13051
+ }
13052
+ this.stream = openAppend(this.path);
13053
+ this.size = sizeOf(this.path);
13054
+ }
13055
+ };
13056
+ function openAppend(path) {
13057
+ return createWriteStream("", { fd: openSync2(path, "a") });
13058
+ }
13059
+ function sizeOf(path) {
13060
+ try {
13061
+ return statSync2(path).size;
13062
+ } catch {
13063
+ return 0;
13064
+ }
13065
+ }
12949
13066
  var cached = null;
12950
13067
  var consoleLogging = true;
12951
13068
  var overrides = {};
@@ -12964,7 +13081,7 @@ function configureLogger(log, config) {
12964
13081
  function createLogger(destinations = {}, options = {}) {
12965
13082
  const path = companionLogPath();
12966
13083
  if (!destinations.file) mkdirSync5(dirname4(path), { recursive: true });
12967
- const file = destinations.file ?? createWriteStream(path, { flags: "a" });
13084
+ const file = destinations.file ?? new RotatingLog(path);
12968
13085
  const state = { format: options.logFormat ?? "human" };
12969
13086
  const streams = [];
12970
13087
  const human = (chunk) => {
@@ -13022,7 +13139,7 @@ import { serve } from "@hono/node-server";
13022
13139
  import { Hono } from "hono";
13023
13140
 
13024
13141
  // src/dashboard/routes.ts
13025
- import { openSync as openSync2, readSync, closeSync as closeSync2, fstatSync, existsSync as existsSync6 } from "fs";
13142
+ import { openSync as openSync3, readSync, closeSync as closeSync2, fstatSync, existsSync as existsSync6 } from "fs";
13026
13143
  import { readFile } from "fs/promises";
13027
13144
  import { extname, join as join9, normalize } from "path";
13028
13145
  import { streamSSE } from "hono/streaming";
@@ -13406,7 +13523,7 @@ function tailFile(path, lines2) {
13406
13523
  const MAX_BYTES = 256 * 1024;
13407
13524
  let fd;
13408
13525
  try {
13409
- fd = openSync2(path, "r");
13526
+ fd = openSync3(path, "r");
13410
13527
  const size = fstatSync(fd).size;
13411
13528
  const start2 = Math.max(0, size - MAX_BYTES);
13412
13529
  const len = size - start2;
@@ -13657,7 +13774,7 @@ init_config();
13657
13774
 
13658
13775
  // src/dispatch-dedupe.ts
13659
13776
  init_config();
13660
- import { mkdirSync as mkdirSync6, readFileSync as readFileSync4, writeFileSync as writeFileSync4, renameSync as renameSync3, existsSync as existsSync7 } from "fs";
13777
+ import { mkdirSync as mkdirSync6, readFileSync as readFileSync4, writeFileSync as writeFileSync4, renameSync as renameSync4, existsSync as existsSync7 } from "fs";
13661
13778
  import { join as join11 } from "path";
13662
13779
  var MAX_IDS = 256;
13663
13780
  function dir(log) {
@@ -13775,7 +13892,7 @@ function writeTurnIds(workspaceId, turns) {
13775
13892
  const path = turnPathFor(workspaceId);
13776
13893
  const tmp = `${path}.${process.pid}.tmp`;
13777
13894
  writeFileSync4(tmp, entries.map(([id, t]) => `${id} ${t}`).join("\n") + "\n", "utf8");
13778
- renameSync3(tmp, path);
13895
+ renameSync4(tmp, path);
13779
13896
  return entries.length;
13780
13897
  }
13781
13898
  function rememberTurnId(workspaceId, eventId, turnId) {
@@ -13971,9 +14088,9 @@ import {
13971
14088
  mkdirSync as mkdirSync10,
13972
14089
  readdirSync as readdirSync4,
13973
14090
  readFileSync as readFileSync9,
13974
- renameSync as renameSync5,
14091
+ renameSync as renameSync6,
13975
14092
  rmSync as rmSync9,
13976
- statSync as statSync3,
14093
+ statSync as statSync4,
13977
14094
  writeFileSync as writeFileSync6
13978
14095
  } from "fs";
13979
14096
  import { join as join20 } from "path";
@@ -14046,7 +14163,7 @@ import {
14046
14163
  mkdirSync as mkdirSync9,
14047
14164
  readdirSync as readdirSync3,
14048
14165
  readFileSync as readFileSync8,
14049
- renameSync as renameSync4,
14166
+ renameSync as renameSync5,
14050
14167
  rmSync as rmSync8
14051
14168
  } from "fs";
14052
14169
  import { join as join18 } from "path";
@@ -14082,11 +14199,11 @@ function admitCollectedEntry(raw, turn) {
14082
14199
  }
14083
14200
  var atomicMove = (from, to) => {
14084
14201
  try {
14085
- renameSync4(from, to);
14202
+ renameSync5(from, to);
14086
14203
  } catch {
14087
14204
  const partial = `${to}.partial`;
14088
14205
  copyFileSync2(from, partial);
14089
- renameSync4(partial, to);
14206
+ renameSync5(partial, to);
14090
14207
  rmSync8(from, { force: true });
14091
14208
  }
14092
14209
  };
@@ -14330,6 +14447,25 @@ function pruneOtherAttempts(deviceDir, pair2, keep) {
14330
14447
  }
14331
14448
  }
14332
14449
  }
14450
+ var IDLE_SESSION_ARCHIVE_MS = 30 * 24 * 60 * 6e4;
14451
+ function pruneIdleSessionArchives(deviceDir, now = Date.now(), maxAgeMs = IDLE_SESSION_ARCHIVE_MS) {
14452
+ const root = join20(deviceDir, "attempts");
14453
+ if (!existsSync14(root)) return [];
14454
+ const removed = [];
14455
+ for (const id of readdirSync4(root)) {
14456
+ if (!UUID_RE.test(id)) continue;
14457
+ let kept;
14458
+ try {
14459
+ kept = statSync4(join20(root, id)).mtimeMs;
14460
+ } catch {
14461
+ continue;
14462
+ }
14463
+ if (now - kept < maxAgeMs) continue;
14464
+ rmSync9(join20(root, id), { recursive: true, force: true });
14465
+ removed.push(id);
14466
+ }
14467
+ return removed;
14468
+ }
14333
14469
  var CappedFile = class extends Writable {
14334
14470
  constructor(path, cap) {
14335
14471
  super();
@@ -14707,7 +14843,7 @@ function createDockerProvider(opts) {
14707
14843
  (resolve2) => file.writableFinished ? resolve2() : file.once("finish", resolve2)
14708
14844
  );
14709
14845
  if (r.code === 0 && !file.over) {
14710
- renameSync5(partial, archive);
14846
+ renameSync6(partial, archive);
14711
14847
  writeFileSync6(join20(archive, "..", "pair"), `${attempt.conversationId}/${attempt.agentId}`);
14712
14848
  pruneOtherAttempts(opts.deviceDir, attempt, attemptId);
14713
14849
  await writeMarker(name, attemptId);
@@ -14844,7 +14980,7 @@ function createDockerProvider(opts) {
14844
14980
  );
14845
14981
  } else {
14846
14982
  log.info(
14847
- { container: name, attemptId, bytes: statSync3(archive).size },
14983
+ { container: name, attemptId, bytes: statSync4(archive).size },
14848
14984
  "runners: session files restored"
14849
14985
  );
14850
14986
  }
@@ -15308,6 +15444,12 @@ var SseSubscriber = class {
15308
15444
  this.aborted = true;
15309
15445
  this.controller?.abort();
15310
15446
  }
15447
+ // Connect again after a `stop()`, once its loop has exited.
15448
+ async resume() {
15449
+ await this.finished;
15450
+ this.aborted = false;
15451
+ this.start();
15452
+ }
15311
15453
  setCursor(eventId) {
15312
15454
  this.lastEventId = eventId;
15313
15455
  }
@@ -15396,9 +15538,564 @@ function sleep4(ms) {
15396
15538
 
15397
15539
  // src/supervisor.ts
15398
15540
  init_transcript_writer();
15541
+
15542
+ // src/updater.ts
15543
+ import { spawn as spawn8 } from "child_process";
15544
+ import { copyFileSync as copyFileSync3, existsSync as existsSync17, mkdirSync as mkdirSync16, renameSync as renameSync11, rmSync as rmSync14 } from "fs";
15545
+ import { dirname as dirname12, join as join26 } from "path";
15546
+
15547
+ // src/boot-rule.ts
15548
+ import {
15549
+ mkdirSync as mkdirSync13,
15550
+ readFileSync as readFileSync12,
15551
+ realpathSync as realpathSync5,
15552
+ renameSync as renameSync8,
15553
+ rmSync as rmSync12,
15554
+ symlinkSync,
15555
+ writeFileSync as writeFileSync9
15556
+ } from "fs";
15557
+ import { dirname as dirname9, join as join23 } from "path";
15558
+ var UNCONFIRMED_WINDOW_MS = 10 * 6e4;
15559
+ var MAX_STAGE_ATTEMPTS = 3;
15560
+ function superseded(pending, ...running) {
15561
+ return !!pending && !running.includes(pending.to);
15562
+ }
15563
+ function rolledBackState(state, pending, reason, now) {
15564
+ const { pending: _done, ...rest } = state;
15565
+ return {
15566
+ ...rest,
15567
+ lastResult: {
15568
+ kind: "rolled_back",
15569
+ from: pending.from,
15570
+ to: pending.to,
15571
+ at: now.toISOString(),
15572
+ reason: reason.replace(/\s+/g, " ").trim().slice(0, 400)
15573
+ },
15574
+ failed: { version: pending.to, attempts: MAX_STAGE_ATTEMPTS, at: now.toISOString() }
15575
+ };
15576
+ }
15577
+
15578
+ // src/install-layout.ts
15579
+ init_config();
15580
+ import {
15581
+ existsSync as existsSync16,
15582
+ lstatSync,
15583
+ mkdirSync as mkdirSync14,
15584
+ readFileSync as readFileSync13,
15585
+ readdirSync as readdirSync6,
15586
+ realpathSync as realpathSync6,
15587
+ renameSync as renameSync9,
15588
+ rmSync as rmSync13,
15589
+ symlinkSync as symlinkSync2
15590
+ } from "fs";
15591
+ import { dirname as dirname10, join as join24 } from "path";
15592
+ import { fileURLToPath as fileURLToPath6 } from "url";
15593
+ function versionsDir() {
15594
+ return join24(cabaneDir(), "versions");
15595
+ }
15596
+ function currentLink() {
15597
+ return join24(cabaneDir(), "current");
15598
+ }
15599
+ function bootGuardPath() {
15600
+ return join24(cabaneDir(), "boot-guard.mjs");
15601
+ }
15602
+ function pinnedNodeDir() {
15603
+ return join24(cabaneDir(), "node");
15604
+ }
15605
+ function pinnedNodePath() {
15606
+ return join24(pinnedNodeDir(), "bin", "node");
15607
+ }
15608
+ function versionPackageRoot(version) {
15609
+ return join24(versionsDir(), version, "node_modules", "@cabane", "companion");
15610
+ }
15611
+ function ownPackageRoot(moduleUrl = import.meta.url) {
15612
+ return dirname10(dirname10(fileURLToPath6(moduleUrl)));
15613
+ }
15614
+ function packageVersion(root) {
15615
+ try {
15616
+ const pkg2 = JSON.parse(readFileSync13(join24(root, "package.json"), "utf8"));
15617
+ return typeof pkg2.version === "string" ? pkg2.version : null;
15618
+ } catch {
15619
+ return null;
15620
+ }
15621
+ }
15622
+ function currentRoot() {
15623
+ try {
15624
+ const root = realpathSync6(currentLink());
15625
+ return existsSync16(join24(root, "dist", "cli.js")) ? root : null;
15626
+ } catch {
15627
+ return null;
15628
+ }
15629
+ }
15630
+ function pointCurrentAt(root) {
15631
+ const link = currentLink();
15632
+ mkdirSync14(dirname10(link), { recursive: true });
15633
+ const fresh = `${link}.${process.pid}.${Date.now()}`;
15634
+ symlinkSync2(root, fresh);
15635
+ try {
15636
+ renameSync9(fresh, link);
15637
+ } catch (err) {
15638
+ rmSync13(fresh, { force: true });
15639
+ throw err;
15640
+ }
15641
+ }
15642
+ function pruneVersions(keep) {
15643
+ const dir2 = versionsDir();
15644
+ if (!existsSync16(dir2)) return [];
15645
+ const removed = [];
15646
+ for (const name of readdirSync6(dir2)) {
15647
+ if (keep.includes(name)) continue;
15648
+ rmSync13(join24(dir2, name), { recursive: true, force: true });
15649
+ removed.push(name);
15650
+ }
15651
+ return removed;
15652
+ }
15653
+ function compareVersions(a, b) {
15654
+ const parts = (v) => /^\d+(\.\d+)*$/.test(v) ? v.split(".").map(Number) : null;
15655
+ const pa = parts(a);
15656
+ const pb = parts(b);
15657
+ if (!pa || !pb) return pa ? 1 : pb ? -1 : 0;
15658
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
15659
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
15660
+ if (d !== 0) return d;
15661
+ }
15662
+ return 0;
15663
+ }
15664
+
15665
+ // src/update-state.ts
15666
+ init_config();
15667
+ import { mkdirSync as mkdirSync15, readFileSync as readFileSync14, renameSync as renameSync10, writeFileSync as writeFileSync10 } from "fs";
15668
+ import { dirname as dirname11, join as join25 } from "path";
15669
+ import { z as z18 } from "zod";
15670
+ var resultSchema = z18.object({
15671
+ kind: z18.enum(["updated", "rolled_back", "failed"]),
15672
+ from: z18.string(),
15673
+ to: z18.string(),
15674
+ at: z18.string(),
15675
+ reason: z18.string().optional()
15676
+ });
15677
+ var pendingSchema = z18.object({
15678
+ from: z18.string(),
15679
+ // The package root `current` pointed at before the swap, so a rollback can point it back.
15680
+ fromRoot: z18.string(),
15681
+ to: z18.string(),
15682
+ at: z18.string(),
15683
+ boots: z18.number().int().nonnegative()
15684
+ });
15685
+ var stateSchema = z18.object({
15686
+ server: z18.object({ latest: z18.string(), minimum: z18.string(), at: z18.string() }).optional(),
15687
+ pending: pendingSchema.optional(),
15688
+ lastResult: resultSchema.optional(),
15689
+ failed: z18.object({ version: z18.string(), attempts: z18.number().int(), at: z18.string() }).optional()
15690
+ });
15691
+ function updateStatePath(dir2 = cabaneDir()) {
15692
+ return join25(dir2, "update.json");
15693
+ }
15694
+ function readUpdateState(path = updateStatePath()) {
15695
+ try {
15696
+ const parsed = stateSchema.safeParse(JSON.parse(readFileSync14(path, "utf8")));
15697
+ return parsed.success ? parsed.data : {};
15698
+ } catch {
15699
+ return {};
15700
+ }
15701
+ }
15702
+ function writeUpdateState(state, path = updateStatePath()) {
15703
+ mkdirSync15(dirname11(path), { recursive: true });
15704
+ const tmp = `${path}.${process.pid}.tmp`;
15705
+ writeFileSync10(tmp, `${JSON.stringify(state, null, 2)}
15706
+ `, { mode: 384 });
15707
+ renameSync10(tmp, path);
15708
+ }
15709
+ function updateUpdateState(change, path = updateStatePath()) {
15710
+ const next = change(readUpdateState(path));
15711
+ writeUpdateState(next, path);
15712
+ return next;
15713
+ }
15714
+
15715
+ // src/updater.ts
15716
+ function updateMode(env = process.env) {
15717
+ return env.CABANE_COMPANION_SUPERVISED === "1" ? "supervised" : "attached";
15718
+ }
15719
+ var STAGE_RETRY_MS = 60 * 6e4;
15720
+ var SWAP_DRAIN_MS = 1e4;
15721
+ var SOURCE_BUILD_VERSION = "0.0.0";
15722
+ function versionToStage(input) {
15723
+ const { own, state, now, force } = input;
15724
+ if (!input.auto && !force) return null;
15725
+ if (own === SOURCE_BUILD_VERSION) return null;
15726
+ const latest = state.server?.latest;
15727
+ if (!latest || compareVersions(latest, own) <= 0) return null;
15728
+ if (state.pending) return null;
15729
+ const failed = state.failed;
15730
+ if (!force && failed?.version === latest) {
15731
+ if (failed.attempts >= MAX_STAGE_ATTEMPTS) return null;
15732
+ if (now - Date.parse(failed.at) < STAGE_RETRY_MS) return null;
15733
+ }
15734
+ return latest;
15735
+ }
15736
+ function rollBack(pending, reason, now = /* @__PURE__ */ new Date()) {
15737
+ if (existsSync17(pending.fromRoot)) pointCurrentAt(pending.fromRoot);
15738
+ return updateUpdateState((state) => rolledBackState(state, pending, reason, now));
15739
+ }
15740
+ function writeBootGuard(root = ownPackageRoot()) {
15741
+ const built = join26(root, "dist", "boot-guard.js");
15742
+ if (!existsSync17(built)) return false;
15743
+ const target = bootGuardPath();
15744
+ mkdirSync16(dirname12(target), { recursive: true });
15745
+ const tmp = `${target}.${process.pid}.tmp`;
15746
+ copyFileSync3(built, tmp);
15747
+ renameSync11(tmp, target);
15748
+ return true;
15749
+ }
15750
+ function oneLine(text) {
15751
+ return text.replace(/\s+/g, " ").trim().slice(0, 400);
15752
+ }
15753
+ var runProcess = (command, args, opts = {}) => new Promise((resolve2) => {
15754
+ let stdout = "";
15755
+ let stderr = "";
15756
+ let child;
15757
+ try {
15758
+ child = spawn8(command, [...args], {
15759
+ stdio: ["ignore", "pipe", "pipe"],
15760
+ env: opts.env ?? process.env
15761
+ });
15762
+ } catch (err) {
15763
+ resolve2({ code: null, stdout, stderr: err instanceof Error ? err.message : String(err) });
15764
+ return;
15765
+ }
15766
+ const timer = opts.timeoutMs ? setTimeout(() => child.kill("SIGKILL"), opts.timeoutMs) : null;
15767
+ child.stdout.on("data", (d) => stdout += d.toString());
15768
+ child.stderr.on("data", (d) => stderr += d.toString());
15769
+ child.on("error", (err) => {
15770
+ if (timer) clearTimeout(timer);
15771
+ resolve2({ code: null, stdout, stderr: stderr || err.message });
15772
+ });
15773
+ child.on("close", (code) => {
15774
+ if (timer) clearTimeout(timer);
15775
+ resolve2({ code, stdout, stderr });
15776
+ });
15777
+ });
15778
+ async function stageVersion(version, harnesses, deps = {}) {
15779
+ const run = deps.run ?? runProcess;
15780
+ const node = deps.execPath ?? process.execPath;
15781
+ const target = join26(versionsDir(), version);
15782
+ const placed = versionPackageRoot(version);
15783
+ if (existsSync17(placed)) {
15784
+ if (!await verifyInstall(placed, version, harnesses, { run, execPath: node }))
15785
+ return { ok: true, root: placed };
15786
+ }
15787
+ const npm = join26(dirname12(node), "npm");
15788
+ if (!existsSync17(npm))
15789
+ return { ok: false, reason: `there is no npm beside Node at ${dirname12(node)}` };
15790
+ mkdirSync16(versionsDir(), { recursive: true });
15791
+ const staging = join26(versionsDir(), `.${version}.staging-${process.pid}`);
15792
+ rmSync14(staging, { recursive: true, force: true });
15793
+ const installed = await run(
15794
+ npm,
15795
+ [
15796
+ "install",
15797
+ "--prefix",
15798
+ staging,
15799
+ "--include=optional",
15800
+ "--no-audit",
15801
+ "--no-fund",
15802
+ "--no-save",
15803
+ "--loglevel=error",
15804
+ `@cabane/companion@${version}`
15805
+ ],
15806
+ {
15807
+ // npm's install scripts run on this Node, not whichever `node` is first on PATH.
15808
+ env: { ...process.env, PATH: `${dirname12(node)}:${process.env.PATH ?? ""}` },
15809
+ timeoutMs: 15 * 6e4
15810
+ }
15811
+ );
15812
+ if (installed.code !== 0) {
15813
+ rmSync14(staging, { recursive: true, force: true });
15814
+ if (/\bE404\b|\bETARGET\b|No matching version found|is not in this registry/i.test(
15815
+ installed.stderr
15816
+ ))
15817
+ return { ok: false, reason: `version ${version} isn't published yet`, unpublished: true };
15818
+ return { ok: false, reason: `npm couldn't install it: ${lastLine(installed.stderr)}` };
15819
+ }
15820
+ const root = join26(staging, "node_modules", "@cabane", "companion");
15821
+ const verdict = await verifyInstall(root, version, harnesses, { run, execPath: node });
15822
+ if (verdict) {
15823
+ rmSync14(staging, { recursive: true, force: true });
15824
+ return { ok: false, reason: verdict };
15825
+ }
15826
+ rmSync14(target, { recursive: true, force: true });
15827
+ renameSync11(staging, target);
15828
+ return { ok: true, root: versionPackageRoot(version) };
15829
+ }
15830
+ async function verifyInstall(root, version, harnesses, deps) {
15831
+ if (packageVersion(root) !== version) return `the installed package isn't version ${version}`;
15832
+ const answered = await deps.run(deps.execPath, [join26(root, "dist", "bin.js"), "--version"], {
15833
+ timeoutMs: 6e4,
15834
+ env: { ...process.env, CABANE_LAUNCH_SELF: "1" }
15835
+ });
15836
+ if (answered.code !== 0 || answered.stdout.trim() !== version) {
15837
+ return `it doesn't start: ${lastLine(answered.stderr) || "no version printed"}`;
15838
+ }
15839
+ if (harnesses.length === 0) return null;
15840
+ const probed = await deps.run(deps.execPath, [join26(root, "dist", "postinstall.js"), "--json"], {
15841
+ timeoutMs: 6e4
15842
+ });
15843
+ let resolutions = {};
15844
+ try {
15845
+ resolutions = JSON.parse(probed.stdout);
15846
+ } catch {
15847
+ return "it couldn't say which bundled agents it has";
15848
+ }
15849
+ const missing = harnesses.filter((h) => resolutions[h]?.status !== "present");
15850
+ if (missing.length > 0) {
15851
+ return `its bundled ${missing.map(harnessName).join(" and ")} ${missing.length > 1 ? "are" : "is"} missing`;
15852
+ }
15853
+ return null;
15854
+ }
15855
+ function harnessName(h) {
15856
+ return h === "claude-code" ? "Claude Code" : "Codex";
15857
+ }
15858
+ function lastLine(text) {
15859
+ const lines2 = text.split("\n").map((l) => l.trim()).filter(Boolean);
15860
+ return lines2.at(-1)?.slice(0, 300) ?? "";
15861
+ }
15862
+ var CompanionUpdater = class {
15863
+ constructor(deps) {
15864
+ this.deps = deps;
15865
+ this.now = deps.now ?? Date.now;
15866
+ }
15867
+ deps;
15868
+ staging = null;
15869
+ ready = null;
15870
+ swapping = false;
15871
+ watchdog = null;
15872
+ now;
15873
+ // At start: a recorded swap to a version that will never start again (someone installed or
15874
+ // chose another since) is dropped, or nothing would ever confirm it and no later update would
15875
+ // be installed.
15876
+ start() {
15877
+ const pending = readUpdateState().pending;
15878
+ const current = currentRoot();
15879
+ const currentVersion = current ? packageVersion(current) : null;
15880
+ if (superseded(pending, this.deps.own, ...currentVersion ? [currentVersion] : [])) {
15881
+ updateUpdateState(({ pending: _gone, ...rest }) => rest);
15882
+ this.deps.log.info(
15883
+ { to: pending.to, own: this.deps.own },
15884
+ `Dropped the update to ${pending.to}: Cabane ${this.deps.own} was installed since`
15885
+ );
15886
+ }
15887
+ }
15888
+ // The deadline for an update this process is the result of: ten minutes from the first time
15889
+ // Cabane answers it without its stream connecting. Until Cabane answers, there's no verdict on
15890
+ // time: a server or network that is down says nothing about the version.
15891
+ armDeadline() {
15892
+ if (this.watchdog) return;
15893
+ this.watchdog = setTimeout(() => void this.giveUp(), UNCONFIRMED_WINDOW_MS);
15894
+ this.watchdog.unref?.();
15895
+ }
15896
+ stop() {
15897
+ if (this.watchdog) clearTimeout(this.watchdog);
15898
+ this.watchdog = null;
15899
+ }
15900
+ async giveUp() {
15901
+ const pending = readUpdateState().pending;
15902
+ if (!pending || pending.to !== this.deps.own) return;
15903
+ this.deps.log.warn(
15904
+ { from: pending.from, to: pending.to },
15905
+ `Cabane ${pending.to} didn't connect within ${UNCONFIRMED_WINDOW_MS / 6e4} minutes of Cabane answering; going back to ${pending.from}`
15906
+ );
15907
+ rollBack(
15908
+ pending,
15909
+ `it didn't connect to Cabane within ${UNCONFIRMED_WINDOW_MS / 6e4} minutes`
15910
+ );
15911
+ await this.deps.restart();
15912
+ }
15913
+ // Auto-update is on and something will restart this companion into the new version.
15914
+ updatesItself() {
15915
+ return this.deps.mode === "supervised" && this.deps.autoUpdate();
15916
+ }
15917
+ // What the heartbeat reports.
15918
+ report() {
15919
+ const last = readUpdateState().lastResult;
15920
+ return { auto: this.updatesItself(), ...last ? { lastResult: last } : {} };
15921
+ }
15922
+ // A heartbeat landed. Record what the server named; confirm an update this process is the
15923
+ // result of; then, when there is something newer, install it and swap when idle.
15924
+ async onHeartbeat(server, streamConnected) {
15925
+ const state = updateUpdateState((s) => ({
15926
+ ...s,
15927
+ ...server ? { server: { ...server, at: new Date(this.now()).toISOString() } } : {}
15928
+ }));
15929
+ if (state.pending?.to === this.deps.own) {
15930
+ if (streamConnected) this.confirm(state.pending);
15931
+ else this.armDeadline();
15932
+ }
15933
+ if (this.ready) {
15934
+ await this.trySwap();
15935
+ return;
15936
+ }
15937
+ this.maybeStage(false);
15938
+ }
15939
+ confirm(pending) {
15940
+ this.stop();
15941
+ updateUpdateState((s) => {
15942
+ const { pending: _done, failed: _failed, ...rest } = s;
15943
+ return {
15944
+ ...rest,
15945
+ lastResult: {
15946
+ kind: "updated",
15947
+ from: pending.from,
15948
+ to: pending.to,
15949
+ at: new Date(this.now()).toISOString()
15950
+ }
15951
+ };
15952
+ });
15953
+ const removed = pruneVersions([pending.to, pending.from]);
15954
+ this.deps.log.info(
15955
+ { from: pending.from, to: pending.to, removed },
15956
+ `Updated Cabane from ${pending.from} to ${pending.to}`
15957
+ );
15958
+ }
15959
+ // `cabane update`, from the person: install the latest now, whatever the setting.
15960
+ async updateNow() {
15961
+ const state = readUpdateState();
15962
+ const latest = state.server?.latest;
15963
+ if (!latest) return { ok: false, message: "Cabane hasn't said which version is latest yet." };
15964
+ if (compareVersions(latest, this.deps.own) <= 0)
15965
+ return { ok: true, message: `Cabane ${this.deps.own} is up to date.` };
15966
+ if (state.pending?.to === latest) return { ok: true, message: this.waitingMessage(latest) };
15967
+ if (this.ready === latest) {
15968
+ void this.trySwap();
15969
+ return { ok: true, message: this.waitingMessage(latest) };
15970
+ }
15971
+ this.maybeStage(true);
15972
+ if (this.staging) await this.staging;
15973
+ if (this.ready !== latest) {
15974
+ const after = readUpdateState();
15975
+ if (after.pending?.to === latest) return { ok: true, message: this.waitingMessage(latest) };
15976
+ if (after.failed?.version === latest && after.failed.attempts === 0)
15977
+ return { ok: false, message: `Cabane ${latest} isn't published yet. Try again later.` };
15978
+ const failed = after.lastResult;
15979
+ return {
15980
+ ok: false,
15981
+ message: failed?.kind === "failed" && failed.to === latest ? `Couldn't update to ${latest}: ${failed.reason ?? "see cabane logs"}.` : `Couldn't update to ${latest}. cabane logs says why.`
15982
+ };
15983
+ }
15984
+ void this.trySwap();
15985
+ return { ok: true, message: this.waitingMessage(latest) };
15986
+ }
15987
+ waitingMessage(version) {
15988
+ if (this.deps.mode === "attached")
15989
+ return `Cabane ${version} is installed. It runs from the next cabane start.`;
15990
+ return this.deps.isIdle() ? `Updating to ${version} now.` : `Updating to ${version} once the turns running now finish.`;
15991
+ }
15992
+ maybeStage(force) {
15993
+ if (this.staging) return;
15994
+ const version = versionToStage({
15995
+ auto: this.updatesItself(),
15996
+ own: this.deps.own,
15997
+ state: readUpdateState(),
15998
+ now: this.now(),
15999
+ force
16000
+ });
16001
+ if (!version) return;
16002
+ const stage = this.deps.stage ?? stageVersion;
16003
+ this.staging = (async () => {
16004
+ this.deps.log.info({ version }, `Installing Cabane ${version} beside this one`);
16005
+ const outcome = await stage(version, this.deps.harnesses());
16006
+ if (outcome.ok) {
16007
+ this.ready = version;
16008
+ updateUpdateState((s) => {
16009
+ const { failed: _failed, ...rest } = s;
16010
+ return rest;
16011
+ });
16012
+ return;
16013
+ }
16014
+ if (outcome.unpublished) {
16015
+ this.deps.log.info({ version }, `Cabane ${version} isn't published yet; trying later`);
16016
+ updateUpdateState((s) => ({
16017
+ ...s,
16018
+ failed: { version, attempts: 0, at: new Date(this.now()).toISOString() }
16019
+ }));
16020
+ return;
16021
+ }
16022
+ this.deps.log.warn({ version, reason: outcome.reason }, `Couldn't install Cabane ${version}`);
16023
+ updateUpdateState((s) => {
16024
+ const attempts = s.failed?.version === version ? s.failed.attempts + 1 : 1;
16025
+ const at = new Date(this.now()).toISOString();
16026
+ return {
16027
+ ...s,
16028
+ failed: { version, attempts, at },
16029
+ // Reported once it has failed for good, so a registry blip isn't an email.
16030
+ ...attempts >= MAX_STAGE_ATTEMPTS || force ? {
16031
+ lastResult: {
16032
+ kind: "failed",
16033
+ from: this.deps.own,
16034
+ to: version,
16035
+ at,
16036
+ reason: oneLine(outcome.reason)
16037
+ }
16038
+ } : {}
16039
+ };
16040
+ });
16041
+ })().finally(() => {
16042
+ this.staging = null;
16043
+ });
16044
+ void this.staging.then(() => this.trySwap());
16045
+ }
16046
+ // Swap into the ready version once nothing is running. Called on each heartbeat until it can.
16047
+ async trySwap() {
16048
+ const to = this.ready;
16049
+ if (!to || this.swapping) return;
16050
+ if (readUpdateState().pending) return;
16051
+ if (this.deps.mode === "attached") {
16052
+ this.select(to);
16053
+ this.deps.log.info({ version: to }, `Cabane ${to} is installed; it runs from the next start`);
16054
+ return;
16055
+ }
16056
+ if (!this.deps.isIdle()) return;
16057
+ this.swapping = true;
16058
+ try {
16059
+ await this.deps.pauseStream();
16060
+ const sleep5 = this.deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
16061
+ const deadline = this.now() + SWAP_DRAIN_MS;
16062
+ await sleep5(250);
16063
+ while (!this.deps.isIdle() && this.now() < deadline) await sleep5(250);
16064
+ if (!this.deps.isIdle()) {
16065
+ this.deps.resumeStream();
16066
+ return;
16067
+ }
16068
+ this.select(to);
16069
+ this.deps.log.info({ from: this.deps.own, to }, `Restarting into Cabane ${to}`);
16070
+ await this.deps.restart();
16071
+ } finally {
16072
+ this.swapping = false;
16073
+ }
16074
+ }
16075
+ // Record the swap and point `current` at `to`, leaving the guard that counts its starts.
16076
+ select(to) {
16077
+ const fromRoot = currentRoot() ?? ownPackageRoot();
16078
+ writeBootGuard();
16079
+ updateUpdateState((s) => ({
16080
+ ...s,
16081
+ pending: {
16082
+ from: this.deps.own,
16083
+ fromRoot,
16084
+ to,
16085
+ at: new Date(this.now()).toISOString(),
16086
+ boots: 0
16087
+ }
16088
+ }));
16089
+ pointCurrentAt(versionPackageRoot(to));
16090
+ this.ready = null;
16091
+ }
16092
+ };
16093
+
16094
+ // src/supervisor.ts
15399
16095
  init_version2();
15400
16096
  init_src();
15401
16097
  var HEARTBEAT_INTERVAL_MS = 3e4;
16098
+ var ARCHIVE_PRUNE_MS = 24 * 60 * 6e4;
15402
16099
  var SHUTDOWN_HEARTBEAT_WAIT_MS = 1e3;
15403
16100
  function positiveIntEnv(name) {
15404
16101
  const raw = process.env[name];
@@ -15480,6 +16177,10 @@ var CompanionSupervisor = class {
15480
16177
  // Which dispatches this process is running, so a redelivered frame doesn't
15481
16178
  // run the turn a second time.
15482
16179
  claims = new DispatchClaims();
16180
+ archivePruneTimer = null;
16181
+ // Keeps this companion current when its person said so; null until paired.
16182
+ updater = null;
16183
+ restartForUpdate;
15483
16184
  constructor(opts) {
15484
16185
  this.config = opts.config;
15485
16186
  this.log = opts.log;
@@ -15489,6 +16190,10 @@ var CompanionSupervisor = class {
15489
16190
  this.harnessVersions = opts.harnessVersions ?? emptyHarnessVersions();
15490
16191
  this.exitFn = opts.exit ?? ((code) => process.exit(code));
15491
16192
  this.reexecFn = opts.reexec ?? defaultReexec;
16193
+ this.restartForUpdate = opts.restartForUpdate ?? (async () => {
16194
+ await this.shutdown();
16195
+ this.exitFn(0);
16196
+ });
15492
16197
  this.dispatcherFactory = opts.dispatcherFactory;
15493
16198
  if (opts.runnerCommand) this.runnerCommand = opts.runnerCommand;
15494
16199
  if (opts.runnerProvider) this.providerForRunners = opts.runnerProvider;
@@ -15539,6 +16244,9 @@ var CompanionSupervisor = class {
15539
16244
  if (!this.runnerImage) await invalidateCompanionVolume(docker, image, source);
15540
16245
  }
15541
16246
  this.latchBootBundledState();
16247
+ this.pruneSessionArchives();
16248
+ this.archivePruneTimer = setInterval(() => this.pruneSessionArchives(), ARCHIVE_PRUNE_MS);
16249
+ this.archivePruneTimer.unref?.();
15542
16250
  }
15543
16251
  const sandboxRows = agentSandboxRows(this.config);
15544
16252
  if (sandboxRows.length > 0 && this.dockerRunners()) {
@@ -15548,7 +16256,7 @@ var CompanionSupervisor = class {
15548
16256
  );
15549
16257
  }
15550
16258
  const anySandboxed = [{}, ...sandboxRows.map(([, sandbox]) => ({ sandbox }))].some(
15551
- (row2) => this.runnerSandboxFor(row2) === "os"
16259
+ (row3) => this.runnerSandboxFor(row3) === "os"
15552
16260
  );
15553
16261
  if (anySandboxed) {
15554
16262
  const policy = this.sandboxPolicy();
@@ -15602,6 +16310,23 @@ var CompanionSupervisor = class {
15602
16310
  );
15603
16311
  }
15604
16312
  });
16313
+ this.updater = new CompanionUpdater({
16314
+ own: COMPANION_VERSION,
16315
+ mode: updateMode(),
16316
+ autoUpdate: () => this.config.update?.auto === true,
16317
+ harnesses: () => this.connectedBundledHarnesses(),
16318
+ isIdle: () => this.idle(),
16319
+ pauseStream: async () => {
16320
+ const sub = this.deviceSub;
16321
+ if (!sub) return;
16322
+ sub.stop();
16323
+ await sub.finished;
16324
+ },
16325
+ resumeStream: () => void this.deviceSub?.resume(),
16326
+ restart: () => this.restartForUpdate(),
16327
+ log: this.log
16328
+ });
16329
+ this.updater.start();
15605
16330
  await this.refreshAssignments();
15606
16331
  this.deviceSub.start();
15607
16332
  const firstHeartbeat = startupHarnessRefresh.then(async () => {
@@ -15662,13 +16387,20 @@ var CompanionSupervisor = class {
15662
16387
  // Omitted until the first observed failure or heal, so the server's
15663
16388
  // status-less manifest synthesis still runs for a fresh companion.
15664
16389
  ...connectorReports.length > 0 ? { connectors: connectorReports } : {},
15665
- ...opencodeModels !== null ? { models: opencodeModels } : {}
16390
+ ...opencodeModels !== null ? { models: opencodeModels } : {},
16391
+ ...this.updater ? { update: this.updater.report() } : {}
15666
16392
  });
15667
16393
  this.hub.setDevice({ deviceId: res.deviceId });
15668
16394
  this.deviceId = res.deviceId;
15669
16395
  this.checkVersionSkew(res.serverVersion);
15670
16396
  this.heartbeated = true;
15671
16397
  void this.recoverRuns();
16398
+ void this.updater?.onHeartbeat(res.companion, this.deviceConnected).catch(
16399
+ (err) => this.log.warn(
16400
+ { err: err instanceof Error ? err.message : String(err) },
16401
+ "Couldn't check for a Cabane update; trying again on the next check-in."
16402
+ )
16403
+ );
15672
16404
  } catch (err) {
15673
16405
  this.log.warn(
15674
16406
  { ...apiErrorLogFields(err) },
@@ -15991,8 +16723,8 @@ var CompanionSupervisor = class {
15991
16723
  // The sandbox one agent's turns run under: its `agents` row, then the
15992
16724
  // device's. Resolved from the current config on every ask, so a reload
15993
16725
  // changes it; only the host probe is kept.
15994
- runnerSandboxFor(row2) {
15995
- return runnerSandboxFor(this.config, row2, this.sandboxHost);
16726
+ runnerSandboxFor(row3) {
16727
+ return runnerSandboxFor(this.config, row3, this.sandboxHost);
15996
16728
  }
15997
16729
  // Resolved against the agents this device runs right now.
15998
16730
  runnerManifest() {
@@ -16699,9 +17431,54 @@ var CompanionSupervisor = class {
16699
17431
  }
16700
17432
  }
16701
17433
  }
17434
+ // A long-lived device keeps a session archive per conversation and agent it ever ran; those
17435
+ // nobody has used in a month go.
17436
+ pruneSessionArchives() {
17437
+ try {
17438
+ const removed = pruneIdleSessionArchives(cabaneDir());
17439
+ if (removed.length > 0)
17440
+ this.log.info({ removed: removed.length }, "runners: removed idle session archives");
17441
+ } catch (err) {
17442
+ this.log.warn(
17443
+ { err: err instanceof Error ? err.message : String(err) },
17444
+ "runners: couldn't remove idle session archives"
17445
+ );
17446
+ }
17447
+ }
17448
+ // Nothing running and nothing about to: no dispatch claimed, no turn chained, no recovery
17449
+ // in progress. What a self-update swap waits for.
17450
+ idle() {
17451
+ if (this.claims.size > 0 || this.recovering) return false;
17452
+ for (const wr of this.workspaces.values()) if (wr.chains.size > 0) return false;
17453
+ return true;
17454
+ }
17455
+ // The bundled harnesses this device has connected, which a new version must still carry.
17456
+ connectedBundledHarnesses() {
17457
+ return [
17458
+ ...isClaudeCodeConnected(this.config) ? ["claude-code"] : [],
17459
+ ...isCodexEnabled(this.config) ? ["codex"] : []
17460
+ ];
17461
+ }
17462
+ // `cabane update`, over the control socket: set whether this companion keeps itself current
17463
+ // (`auto`), or install the latest now.
17464
+ async update(auto) {
17465
+ if (auto !== void 0) {
17466
+ this.config = { ...this.config, update: { auto } };
17467
+ saveConfig(this.config);
17468
+ if (auto) this.kickHeartbeat();
17469
+ return {
17470
+ ok: true,
17471
+ message: auto ? "Cabane will keep itself up to date. Updates install between turns and roll back if they fail." : "Cabane won't update itself. Run cabane update to update."
17472
+ };
17473
+ }
17474
+ if (!this.updater) return { ok: false, message: "This computer isn't paired yet." };
17475
+ return this.updater.updateNow();
17476
+ }
16702
17477
  // ---- control actions ----
16703
17478
  async shutdown() {
16704
17479
  this.stopped = true;
17480
+ this.updater?.stop();
17481
+ if (this.archivePruneTimer) clearInterval(this.archivePruneTimer);
16705
17482
  if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
16706
17483
  if (this.pollTimer) clearInterval(this.pollTimer);
16707
17484
  const heartbeat = this.inFlightHeartbeat;
@@ -16736,9 +17513,9 @@ async function waitForBoundedHeartbeat(heartbeat) {
16736
17513
  }
16737
17514
  function defaultReexec() {
16738
17515
  clearRuntimeState();
16739
- void import("child_process").then(({ spawn: spawn10 }) => {
17516
+ void import("child_process").then(({ spawn: spawn11 }) => {
16740
17517
  try {
16741
- const child = spawn10(process.execPath, process.argv.slice(1), {
17518
+ const child = spawn11(process.execPath, process.argv.slice(1), {
16742
17519
  stdio: "inherit",
16743
17520
  detached: false
16744
17521
  });
@@ -16812,22 +17589,22 @@ function handleUncaught(log, err, origin) {
16812
17589
 
16813
17590
  // src/crash-marker.ts
16814
17591
  init_config();
16815
- import { existsSync as existsSync16, mkdirSync as mkdirSync13, readFileSync as readFileSync12, rmSync as rmSync12, writeFileSync as writeFileSync9 } from "fs";
16816
- import { join as join23 } from "path";
17592
+ import { existsSync as existsSync18, mkdirSync as mkdirSync17, readFileSync as readFileSync15, rmSync as rmSync15, writeFileSync as writeFileSync11 } from "fs";
17593
+ import { join as join27 } from "path";
16817
17594
  function crashMarkerPath() {
16818
- return join23(cabaneDir(), "last-error.json");
17595
+ return join27(cabaneDir(), "last-error.json");
16819
17596
  }
16820
17597
  function recordCrash(rec2) {
16821
17598
  try {
16822
- mkdirSync13(cabaneDir(), { recursive: true });
16823
- writeFileSync9(crashMarkerPath(), JSON.stringify(rec2, null, 2) + "\n");
17599
+ mkdirSync17(cabaneDir(), { recursive: true });
17600
+ writeFileSync11(crashMarkerPath(), JSON.stringify(rec2, null, 2) + "\n");
16824
17601
  } catch {
16825
17602
  }
16826
17603
  }
16827
17604
  function clearCrash() {
16828
17605
  try {
16829
17606
  const path = crashMarkerPath();
16830
- if (existsSync16(path)) rmSync12(path, { force: true });
17607
+ if (existsSync18(path)) rmSync15(path, { force: true });
16831
17608
  } catch {
16832
17609
  }
16833
17610
  }
@@ -16882,11 +17659,14 @@ async function createCompanionRuntime(opts = {}) {
16882
17659
  // process behind the marker pid is this companion (not a recycled pid).
16883
17660
  instanceId
16884
17661
  });
17662
+ let restartForUpdate = async () => {
17663
+ };
16885
17664
  const supervisor = new CompanionSupervisor({
16886
17665
  config: cfg,
16887
17666
  log,
16888
17667
  hub,
16889
- ...opts.checkHarness ? { checkHarness: opts.checkHarness } : {}
17668
+ ...opts.checkHarness ? { checkHarness: opts.checkHarness } : {},
17669
+ restartForUpdate: () => restartForUpdate()
16890
17670
  });
16891
17671
  await supervisor.start();
16892
17672
  const connectHarness = async (runtime, serverUrl) => {
@@ -16911,6 +17691,7 @@ async function createCompanionRuntime(opts = {}) {
16911
17691
  control = await startControlServer({
16912
17692
  status: () => hub.statusJson(),
16913
17693
  connect: (runtime, serverUrl) => connectHarness(runtime, serverUrl),
17694
+ update: (auto) => supervisor.update(auto),
16914
17695
  stop: () => void supervisor.requestStop()
16915
17696
  });
16916
17697
  if (opts.dashboard) {
@@ -16963,6 +17744,10 @@ async function createCompanionRuntime(opts = {}) {
16963
17744
  } catch {
16964
17745
  }
16965
17746
  };
17747
+ restartForUpdate = async () => {
17748
+ await stop2();
17749
+ process.exit(0);
17750
+ };
16966
17751
  return {
16967
17752
  ok: true,
16968
17753
  runtime: {
@@ -16984,42 +17769,438 @@ async function closeSurfaces(control, dashboard) {
16984
17769
  // src/commands/start.ts
16985
17770
  init_term();
16986
17771
 
16987
- // src/commands/daemon.ts
16988
- init_cabane_command();
16989
- init_cli_entry();
17772
+ // src/service.ts
16990
17773
  init_config();
16991
- import { spawn as spawn8 } from "child_process";
16992
- import { closeSync as closeSync3, mkdirSync as mkdirSync14, openSync as openSync3 } from "fs";
16993
- var STARTUP_TIMEOUT_MS = 8e3;
16994
- var POLL_INTERVAL_MS = 150;
16995
- async function startDaemon(opts = {}, deps = {}) {
16996
- const ensureRuntime = deps.ensureRuntime ?? warnAboutHarnessReadiness;
16997
- const readState = deps.readState ?? readLiveRuntimeState;
16998
- const verify = deps.verify ?? ((s) => verifyRuntime(s));
16999
- const spawnDetached = deps.spawnDetached ?? defaultSpawnDetached;
17000
- const sleep5 = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
17001
- const now = deps.now ?? (() => Date.now());
17002
- const quiet = opts.report === "failures";
17003
- const cfg = requireStartConfig({
17004
- // Unset → `requireStartConfig`'s own `requireConfig`, the real one.
17005
- ...deps.requireCfg ? { requireCfg: deps.requireCfg } : {},
17006
- ...deps.save ? { save: deps.save } : {},
17007
- // The child won't log it (the block is written by then), and this process owns
17008
- // the user's terminal — so the one-time record is one line, here.
17009
- onMigrated: () => {
17010
- if (!quiet) {
17011
- process.stdout.write(
17012
- "Carried Claude Code over as a connected harness on this device \u2014 connectors are chosen now, not detected.\n"
17013
- );
17014
- }
17015
- }
17774
+ import { createHash as createHash6 } from "crypto";
17775
+ import {
17776
+ chmodSync as chmodSync7,
17777
+ cpSync,
17778
+ existsSync as existsSync19,
17779
+ mkdirSync as mkdirSync18,
17780
+ readFileSync as readFileSync16,
17781
+ renameSync as renameSync12,
17782
+ rmSync as rmSync16,
17783
+ writeFileSync as writeFileSync12
17784
+ } from "fs";
17785
+ import { homedir as homedir7, userInfo as userInfo3 } from "os";
17786
+ import { dirname as dirname13, join as join28, sep as sep5 } from "path";
17787
+ function servicePlatform(platform = process.platform) {
17788
+ if (platform === "linux") return "systemd";
17789
+ if (platform === "darwin") return "launchd";
17790
+ return null;
17791
+ }
17792
+ function serviceName(env = process.env) {
17793
+ const home = env.CABANE_COMPANION_HOME;
17794
+ if (!home) return "cabane-companion";
17795
+ return `cabane-companion-${createHash6("sha256").update(home).digest("hex").slice(0, 8)}`;
17796
+ }
17797
+ function launchdLabel(env = process.env) {
17798
+ return serviceName(env).replace(/^cabane-companion/, "ai.cabane.companion").replace("-", ".");
17799
+ }
17800
+ function serviceNode(execPath = process.execPath) {
17801
+ return existsSync19(pinnedNodePath()) ? pinnedNodePath() : execPath;
17802
+ }
17803
+ function servicePaths(platform, env = process.env, home = homedir7(), node = serviceNode()) {
17804
+ const unitFile = platform === "systemd" ? join28(
17805
+ env.XDG_CONFIG_HOME || join28(home, ".config"),
17806
+ "systemd",
17807
+ "user",
17808
+ `${serviceName(env)}.service`
17809
+ ) : join28(home, "Library", "LaunchAgents", `${launchdLabel(env)}.plist`);
17810
+ return {
17811
+ unitFile,
17812
+ envFile: join28(cabaneDir(), "service.env"),
17813
+ node,
17814
+ entry: bootGuardPath()
17815
+ };
17816
+ }
17817
+ function unitEnvironment(env) {
17818
+ return {
17819
+ CABANE_COMPANION_SUPERVISED: "1",
17820
+ ...env.CABANE_COMPANION_HOME ? { CABANE_COMPANION_HOME: env.CABANE_COMPANION_HOME } : {}
17821
+ };
17822
+ }
17823
+ function renderSystemdUnit(paths, env = process.env) {
17824
+ const environment = Object.entries(unitEnvironment(env)).map(
17825
+ ([key, value]) => `Environment=${systemdQuote(`${key}=${value}`)}`
17826
+ );
17827
+ return [
17828
+ "# Written by `cabane service install`. Remove with `cabane service uninstall`.",
17829
+ "[Unit]",
17830
+ "Description=Cabane companion",
17831
+ "Wants=network-online.target",
17832
+ "After=network-online.target",
17833
+ // Never stop restarting: a machine nobody watches has nobody to reset a failed unit.
17834
+ "StartLimitIntervalSec=0",
17835
+ "",
17836
+ "[Service]",
17837
+ "Type=simple",
17838
+ `ExecStart=${systemdQuote(paths.node)} ${systemdQuote(paths.entry)} start --foreground`,
17839
+ "Restart=always",
17840
+ "RestartSec=5",
17841
+ ...environment,
17842
+ `EnvironmentFile=-${paths.envFile}`,
17843
+ "",
17844
+ "[Install]",
17845
+ "WantedBy=default.target",
17846
+ ""
17847
+ ].join("\n");
17848
+ }
17849
+ function systemdQuote(value) {
17850
+ return /[\s"\\]/.test(value) ? `"${value.replace(/(["\\])/g, "\\$1")}"` : value;
17851
+ }
17852
+ function renderLaunchdPlist(paths, env = process.env, servicePath) {
17853
+ const xml = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
17854
+ const vars = { ...unitEnvironment(env), PATH: servicePath };
17855
+ return [
17856
+ '<?xml version="1.0" encoding="UTF-8"?>',
17857
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
17858
+ "<!-- Written by `cabane service install`. Remove with `cabane service uninstall`. -->",
17859
+ '<plist version="1.0">',
17860
+ "<dict>",
17861
+ ` <key>Label</key><string>${xml(launchdLabel(env))}</string>`,
17862
+ " <key>ProgramArguments</key>",
17863
+ " <array>",
17864
+ ...[paths.node, paths.entry, "start", "--foreground"].map(
17865
+ (arg) => ` <string>${xml(arg)}</string>`
17866
+ ),
17867
+ " </array>",
17868
+ " <key>EnvironmentVariables</key>",
17869
+ " <dict>",
17870
+ ...Object.entries(vars).map(
17871
+ ([key, value]) => ` <key>${xml(key)}</key><string>${xml(value)}</string>`
17872
+ ),
17873
+ " </dict>",
17874
+ " <key>RunAtLoad</key><true/>",
17875
+ " <key>KeepAlive</key><true/>",
17876
+ " <key>ThrottleInterval</key><integer>5</integer>",
17877
+ // The companion writes its own log; the console copy would only grow a second one.
17878
+ " <key>StandardOutPath</key><string>/dev/null</string>",
17879
+ ` <key>StandardErrorPath</key><string>${xml(join28(cabaneDir(), "service-error.log"))}</string>`,
17880
+ "</dict>",
17881
+ "</plist>",
17882
+ ""
17883
+ ].join("\n");
17884
+ }
17885
+ function withServicePath(existing, path) {
17886
+ const kept = existing.split("\n").filter((line) => line.trim() && !/^\s*PATH\s*=/.test(line));
17887
+ return [
17888
+ "# The Cabane service reads this. PATH is your login shell's, written by `cabane service install`.",
17889
+ "# Add a headless sign-in here if this machine has no keychain, e.g. CLAUDE_CODE_OAUTH_TOKEN=\u2026",
17890
+ `PATH=${path}`,
17891
+ ...kept.filter(
17892
+ (line) => !line.startsWith("# The Cabane service reads this") && !line.startsWith("# Add a headless sign-in here")
17893
+ ),
17894
+ ""
17895
+ ].join("\n");
17896
+ }
17897
+ async function loginShellPath(run = runProcess, env = process.env) {
17898
+ const shell = env.SHELL;
17899
+ if (shell) {
17900
+ const r = await run(shell, ["-l", "-c", 'printf "%s" "$PATH"'], { timeoutMs: 1e4 });
17901
+ const path = r.stdout.trim().split("\n").at(-1)?.trim();
17902
+ if (r.code === 0 && path && path.includes("/")) return path;
17903
+ }
17904
+ return env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
17905
+ }
17906
+ async function installService(deps = {}) {
17907
+ const run = deps.run ?? runProcess;
17908
+ const env = deps.env ?? process.env;
17909
+ const platform = servicePlatform(deps.platform);
17910
+ if (!platform)
17911
+ return {
17912
+ ok: false,
17913
+ lines: [
17914
+ "Running Cabane as a service works on macOS and Linux. Start it with cabane start here."
17915
+ ]
17916
+ };
17917
+ const node = serviceNode(deps.execPath);
17918
+ const seeded = await seedManagedInstall({
17919
+ run,
17920
+ execPath: node,
17921
+ ...deps.own ? { own: deps.own } : {}
17016
17922
  });
17017
- ensureRuntime(cfg, {
17018
- // The onboarding script already said what's connectable, in its own
17019
- // words. Saying it again here, on stderr, in the middle of the closing block
17020
- // would be the same news twice.
17021
- ...quiet ? { warn: () => {
17022
- } } : {}
17923
+ if (!seeded.ok) return { ok: false, lines: [seeded.reason] };
17924
+ if (!writeBootGuard(seeded.root))
17925
+ return {
17926
+ ok: false,
17927
+ lines: [
17928
+ "This install has no boot guard to run the service with. Reinstall Cabane, then run cabane service install again."
17929
+ ]
17930
+ };
17931
+ rewriteScriptWrappers();
17932
+ const paths = servicePaths(platform, env, deps.home, node);
17933
+ const servicePath = await loginShellPath(run, env);
17934
+ mkdirSync18(dirname13(paths.envFile), { recursive: true });
17935
+ const existing = existsSync19(paths.envFile) ? readFileSync16(paths.envFile, "utf8") : "";
17936
+ writeFileSync12(paths.envFile, withServicePath(existing, servicePath), { mode: 384 });
17937
+ chmodSync7(paths.envFile, 384);
17938
+ mkdirSync18(dirname13(paths.unitFile), { recursive: true });
17939
+ const outcome = await writeAndStartUnit(platform, paths, env, servicePath, run);
17940
+ if (!outcome.ok) await removeUnit(platform, paths, env, run);
17941
+ return outcome;
17942
+ }
17943
+ async function writeAndStartUnit(platform, paths, env, servicePath, run) {
17944
+ const lines2 = [];
17945
+ if (platform === "systemd") {
17946
+ writeAtomic(paths.unitFile, renderSystemdUnit(paths, env));
17947
+ const unit = `${serviceName(env)}.service`;
17948
+ for (const args of [
17949
+ ["--user", "daemon-reload"],
17950
+ ["--user", "enable", unit],
17951
+ ["--user", "restart", unit]
17952
+ ]) {
17953
+ const r = await run("systemctl", args, { timeoutMs: 3e4 });
17954
+ if (r.code !== 0)
17955
+ return {
17956
+ ok: false,
17957
+ lines: [
17958
+ `systemctl ${args.join(" ")} failed: ${r.stderr.trim() || `exit ${r.code}`}`,
17959
+ "This needs a systemd user session. Start Cabane with cabane start instead."
17960
+ ]
17961
+ };
17962
+ }
17963
+ const linger = await run("loginctl", ["show-user", userName(), "--property=Linger"], {
17964
+ timeoutMs: 1e4
17965
+ });
17966
+ if (linger.code === 0 && /Linger=no/.test(linger.stdout)) {
17967
+ lines2.push(
17968
+ "! It stops when you log out of this computer. To keep it running after logout, run once:",
17969
+ ` sudo loginctl enable-linger ${userName()}`
17970
+ );
17971
+ }
17972
+ } else {
17973
+ writeAtomic(paths.unitFile, renderLaunchdPlist(paths, env, servicePath));
17974
+ const domain = `gui/${process.getuid?.() ?? userInfo3().uid}`;
17975
+ await run("launchctl", ["bootout", `${domain}/${launchdLabel(env)}`], { timeoutMs: 3e4 });
17976
+ const r = await run("launchctl", ["bootstrap", domain, paths.unitFile], { timeoutMs: 3e4 });
17977
+ if (r.code !== 0)
17978
+ return {
17979
+ ok: false,
17980
+ lines: [`launchctl bootstrap failed: ${r.stderr.trim() || `exit ${r.code}`}`]
17981
+ };
17982
+ }
17983
+ return { ok: true, lines: lines2 };
17984
+ }
17985
+ async function uninstallService(deps = {}) {
17986
+ const run = deps.run ?? runProcess;
17987
+ const env = deps.env ?? process.env;
17988
+ const platform = servicePlatform(deps.platform);
17989
+ if (!platform) return { ok: true, lines: [] };
17990
+ const paths = servicePaths(platform, env, deps.home);
17991
+ if (!existsSync19(paths.unitFile)) return { ok: true, lines: ["No Cabane service is installed."] };
17992
+ await removeUnit(platform, paths, env, run);
17993
+ return { ok: true, lines: [] };
17994
+ }
17995
+ async function removeUnit(platform, paths, env, run) {
17996
+ if (platform === "systemd") {
17997
+ await run("systemctl", ["--user", "disable", "--now", `${serviceName(env)}.service`], {
17998
+ timeoutMs: 3e4
17999
+ });
18000
+ rmSync16(paths.unitFile, { force: true });
18001
+ await run("systemctl", ["--user", "daemon-reload"], { timeoutMs: 3e4 });
18002
+ } else {
18003
+ const domain = `gui/${process.getuid?.() ?? userInfo3().uid}`;
18004
+ await run("launchctl", ["bootout", `${domain}/${launchdLabel(env)}`], { timeoutMs: 3e4 });
18005
+ rmSync16(paths.unitFile, { force: true });
18006
+ }
18007
+ }
18008
+ async function seedManagedInstall(deps) {
18009
+ const current = currentRoot();
18010
+ if (current && current.startsWith(versionsDir() + sep5)) return { ok: true, root: current };
18011
+ const own = deps.own ?? ownPackageRoot();
18012
+ const version = packageVersion(own);
18013
+ if (!version || version === "0.0.0") {
18014
+ pointCurrentAt(own);
18015
+ return { ok: true, root: own };
18016
+ }
18017
+ const target = versionPackageRoot(version);
18018
+ if (!existsSync19(target)) {
18019
+ const staging = join28(versionsDir(), `.${version}.seed-${process.pid}`);
18020
+ rmSync16(staging, { recursive: true, force: true });
18021
+ try {
18022
+ cpSync(own, join28(staging, "node_modules", "@cabane", "companion"), {
18023
+ recursive: true,
18024
+ verbatimSymlinks: true
18025
+ });
18026
+ rmSync16(join28(versionsDir(), version), { recursive: true, force: true });
18027
+ renameSync12(staging, join28(versionsDir(), version));
18028
+ } catch (err) {
18029
+ rmSync16(staging, { recursive: true, force: true });
18030
+ return {
18031
+ ok: false,
18032
+ reason: `Couldn't copy Cabane into ${tildeDir()}/versions for the service: ${err instanceof Error ? err.message : String(err)}`
18033
+ };
18034
+ }
18035
+ }
18036
+ const unrunnable = await verifyInstall(target, version, [], deps);
18037
+ if (unrunnable) {
18038
+ rmSync16(join28(versionsDir(), version), { recursive: true, force: true });
18039
+ return { ok: false, reason: `The copy of Cabane for the service can't run: ${unrunnable}.` };
18040
+ }
18041
+ pointCurrentAt(target);
18042
+ return { ok: true, root: target };
18043
+ }
18044
+ function tildeDir() {
18045
+ return cabaneDir().replace(homedir7(), "~");
18046
+ }
18047
+ function unitNode(platform, unitFile) {
18048
+ let text;
18049
+ try {
18050
+ text = readFileSync16(unitFile, "utf8");
18051
+ } catch {
18052
+ return null;
18053
+ }
18054
+ const found = platform === "systemd" ? /^ExecStart=("(?:[^"\\]|\\.)*"|\S+)/m.exec(text)?.[1] : /<key>ProgramArguments<\/key>\s*<array>\s*<string>([^<]*)<\/string>/.exec(text)?.[1];
18055
+ if (!found) return null;
18056
+ return found.startsWith('"') ? found.slice(1, -1).replace(/\\(.)/g, "$1") : found;
18057
+ }
18058
+ async function serviceState(deps = {}) {
18059
+ const run = deps.run ?? runProcess;
18060
+ const env = deps.env ?? process.env;
18061
+ const platform = servicePlatform(deps.platform);
18062
+ const none = {
18063
+ installed: false,
18064
+ platform,
18065
+ state: "not installed",
18066
+ pid: null,
18067
+ node: null,
18068
+ nodeOk: true
18069
+ };
18070
+ if (!platform) return none;
18071
+ const paths = servicePaths(platform, env, deps.home);
18072
+ if (!existsSync19(paths.unitFile)) return none;
18073
+ const node = unitNode(platform, paths.unitFile);
18074
+ const nodeOk = node === null || existsSync19(node);
18075
+ if (platform === "systemd") {
18076
+ const r2 = await run(
18077
+ "systemctl",
18078
+ ["--user", "show", `${serviceName(env)}.service`, "--property=ActiveState,SubState,MainPID"],
18079
+ { timeoutMs: 1e4 }
18080
+ );
18081
+ const prop = (name) => new RegExp(`^${name}=(.*)$`, "m").exec(r2.stdout)?.[1] ?? "";
18082
+ const active = prop("ActiveState");
18083
+ const pid2 = Number(prop("MainPID")) || null;
18084
+ return {
18085
+ installed: true,
18086
+ platform,
18087
+ state: active === "active" ? "running" : prop("SubState") || active || "unknown",
18088
+ pid: pid2,
18089
+ node,
18090
+ nodeOk
18091
+ };
18092
+ }
18093
+ const domain = `gui/${process.getuid?.() ?? userInfo3().uid}`;
18094
+ const r = await run("launchctl", ["print", `${domain}/${launchdLabel(env)}`], {
18095
+ timeoutMs: 1e4
18096
+ });
18097
+ if (r.code !== 0)
18098
+ return { installed: true, platform, state: "not loaded", pid: null, node, nodeOk };
18099
+ const state = /^\s*state = (.+)$/m.exec(r.stdout)?.[1]?.trim() ?? "unknown";
18100
+ const pid = Number(/^\s*pid = (\d+)$/m.exec(r.stdout)?.[1]) || null;
18101
+ return { installed: true, platform, state, pid, node, nodeOk };
18102
+ }
18103
+ function serviceInstalled(deps = {}) {
18104
+ const platform = servicePlatform(deps.platform);
18105
+ if (!platform) return false;
18106
+ return existsSync19(servicePaths(platform, deps.env ?? process.env, deps.home).unitFile);
18107
+ }
18108
+ function writeAtomic(path, content) {
18109
+ const tmp = `${path}.${process.pid}.tmp`;
18110
+ writeFileSync12(tmp, content);
18111
+ renameSync12(tmp, path);
18112
+ }
18113
+ function userName() {
18114
+ try {
18115
+ return userInfo3().username;
18116
+ } catch {
18117
+ return process.env.USER ?? "";
18118
+ }
18119
+ }
18120
+ function rewriteScriptWrappers(dir2 = join28(cabaneDir(), "bin")) {
18121
+ for (const name of ["cabane", "cabane-companion"]) {
18122
+ const path = join28(dir2, name);
18123
+ let text;
18124
+ try {
18125
+ text = readFileSync16(path, "utf8");
18126
+ } catch {
18127
+ continue;
18128
+ }
18129
+ if (!text.includes("Written by https://cabane.ai/install.sh")) continue;
18130
+ if (!text.includes("/dist/cli.js")) continue;
18131
+ writeAtomic(path, text.replace("/dist/cli.js", "/dist/bin.js"));
18132
+ chmodSync7(path, 493);
18133
+ }
18134
+ }
18135
+ async function startService(deps = {}) {
18136
+ return controlService("start", deps);
18137
+ }
18138
+ async function stopService(deps = {}) {
18139
+ return controlService("stop", deps);
18140
+ }
18141
+ async function controlService(action, deps) {
18142
+ const run = deps.run ?? runProcess;
18143
+ const env = deps.env ?? process.env;
18144
+ const platform = servicePlatform(deps.platform);
18145
+ if (!platform) return { ok: false, lines: [] };
18146
+ const paths = servicePaths(platform, env, deps.home);
18147
+ const r = platform === "systemd" ? await run("systemctl", ["--user", action, `${serviceName(env)}.service`], {
18148
+ timeoutMs: 3e4
18149
+ }) : action === "start" ? await run(
18150
+ "launchctl",
18151
+ ["bootstrap", `gui/${process.getuid?.() ?? userInfo3().uid}`, paths.unitFile],
18152
+ {
18153
+ timeoutMs: 3e4
18154
+ }
18155
+ ) : await run(
18156
+ "launchctl",
18157
+ ["bootout", `gui/${process.getuid?.() ?? userInfo3().uid}/${launchdLabel(env)}`],
18158
+ {
18159
+ timeoutMs: 3e4
18160
+ }
18161
+ );
18162
+ return r.code === 0 ? { ok: true, lines: [] } : {
18163
+ ok: false,
18164
+ lines: [`Couldn't ${action} the Cabane service: ${r.stderr.trim() || `exit ${r.code}`}`]
18165
+ };
18166
+ }
18167
+
18168
+ // src/commands/daemon.ts
18169
+ init_cabane_command();
18170
+ init_cli_entry();
18171
+ init_config();
18172
+ import { spawn as spawn9 } from "child_process";
18173
+ import { closeSync as closeSync3, mkdirSync as mkdirSync19, openSync as openSync4 } from "fs";
18174
+ var STARTUP_TIMEOUT_MS = 8e3;
18175
+ var POLL_INTERVAL_MS = 150;
18176
+ async function startDaemon(opts = {}, deps = {}) {
18177
+ const ensureRuntime = deps.ensureRuntime ?? warnAboutHarnessReadiness;
18178
+ const readState = deps.readState ?? readLiveRuntimeState;
18179
+ const verify = deps.verify ?? ((s) => verifyRuntime(s));
18180
+ const spawnDetached = deps.spawnDetached ?? defaultSpawnDetached;
18181
+ const sleep5 = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
18182
+ const now = deps.now ?? (() => Date.now());
18183
+ const quiet = opts.report === "failures";
18184
+ const cfg = requireStartConfig({
18185
+ // Unset → `requireStartConfig`'s own `requireConfig`, the real one.
18186
+ ...deps.requireCfg ? { requireCfg: deps.requireCfg } : {},
18187
+ ...deps.save ? { save: deps.save } : {},
18188
+ // The child won't log it (the block is written by then), and this process owns
18189
+ // the user's terminal — so the one-time record is one line, here.
18190
+ onMigrated: () => {
18191
+ if (!quiet) {
18192
+ process.stdout.write(
18193
+ "Carried Claude Code over as a connected harness on this device \u2014 connectors are chosen now, not detected.\n"
18194
+ );
18195
+ }
18196
+ }
18197
+ });
18198
+ ensureRuntime(cfg, {
18199
+ // The onboarding script already said what's connectable, in its own
18200
+ // words. Saying it again here, on stderr, in the middle of the closing block
18201
+ // would be the same news twice.
18202
+ ...quiet ? { warn: () => {
18203
+ } } : {}
17023
18204
  });
17024
18205
  const existing = readState();
17025
18206
  if (existing) {
@@ -17079,10 +18260,10 @@ function alreadyRunningLines(pid) {
17079
18260
  }
17080
18261
  function defaultSpawnDetached(args) {
17081
18262
  const cliPath = companionCliEntry();
17082
- mkdirSync14(cabaneDir(), { recursive: true });
17083
- const logFd = openSync3(companionLogPath(), "a");
18263
+ mkdirSync19(cabaneDir(), { recursive: true });
18264
+ const logFd = openSync4(companionLogPath(), "a");
17084
18265
  try {
17085
- return spawn8(process.execPath, [cliPath, ...args], {
18266
+ return spawn9(process.execPath, [cliPath, ...args], {
17086
18267
  detached: true,
17087
18268
  stdio: ["ignore", logFd, logFd],
17088
18269
  env: { ...process.env, CABANE_COMPANION_DAEMON: "1" }
@@ -17092,6 +18273,209 @@ function defaultSpawnDetached(args) {
17092
18273
  }
17093
18274
  }
17094
18275
 
18276
+ // src/commands/service.ts
18277
+ init_cabane_command();
18278
+ init_config();
18279
+ init_term();
18280
+ init_version2();
18281
+
18282
+ // src/commands/stop.ts
18283
+ var TERM_GRACE_MS = 6e3;
18284
+ var POLL_INTERVAL_MS2 = 150;
18285
+ async function stop(deps = {}) {
18286
+ if ((deps.serviceInstalled ?? serviceInstalled)()) {
18287
+ const outcome = await (deps.stopService ?? stopService)();
18288
+ for (const line of outcome.lines) process.stdout.write(`${line}
18289
+ `);
18290
+ if (!outcome.ok) {
18291
+ process.exitCode = 1;
18292
+ return;
18293
+ }
18294
+ process.stdout.write(
18295
+ "companion: stopped the service. It starts again at boot, or with `cabane start`.\n"
18296
+ );
18297
+ return;
18298
+ }
18299
+ const readState = deps.readState ?? readLiveRuntimeState;
18300
+ const verify = deps.verify ?? ((s) => verifyRuntime(s));
18301
+ const kill = deps.kill ?? ((pid2, signal) => process.kill(pid2, signal));
18302
+ const sleep5 = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
18303
+ const now = deps.now ?? (() => Date.now());
18304
+ const state = readState();
18305
+ if (!state) {
18306
+ process.stdout.write("companion: not running (nothing to stop).\n");
18307
+ return;
18308
+ }
18309
+ if (await verify(state) === "stale") {
18310
+ clearRuntimeState();
18311
+ process.stdout.write("companion: not running (stale marker swept).\n");
18312
+ return;
18313
+ }
18314
+ const { pid } = state;
18315
+ process.stdout.write(`companion: stopping (pid ${pid})\u2026
18316
+ `);
18317
+ try {
18318
+ kill(pid, "SIGTERM");
18319
+ } catch {
18320
+ clearRuntimeState();
18321
+ process.stdout.write("companion: already stopped.\n");
18322
+ return;
18323
+ }
18324
+ const deadline = now() + TERM_GRACE_MS;
18325
+ while (now() < deadline) {
18326
+ if (!isAlive(kill, pid)) {
18327
+ process.stdout.write("companion: stopped.\n");
18328
+ return;
18329
+ }
18330
+ await sleep5(POLL_INTERVAL_MS2);
18331
+ }
18332
+ process.stdout.write(
18333
+ `companion: didn't exit within ${TERM_GRACE_MS / 1e3}s, sending SIGKILL.
18334
+ `
18335
+ );
18336
+ try {
18337
+ kill(pid, "SIGKILL");
18338
+ } catch {
18339
+ }
18340
+ clearRuntimeState();
18341
+ process.stdout.write("companion: force-stopped.\n");
18342
+ }
18343
+ function isAlive(kill, pid) {
18344
+ try {
18345
+ kill(pid, 0);
18346
+ return true;
18347
+ } catch {
18348
+ return false;
18349
+ }
18350
+ }
18351
+
18352
+ // src/commands/service.ts
18353
+ var STARTUP_WAIT_MS = 2e4;
18354
+ async function installAndStartService(deps = {}) {
18355
+ const readState = deps.readState ?? readLiveRuntimeState;
18356
+ const sleep5 = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
18357
+ const now = deps.now ?? Date.now;
18358
+ const before = await serviceState(deps);
18359
+ const running = readState();
18360
+ if (running && running.pid !== before.pid) await (deps.stopRunning ?? (() => stop()))();
18361
+ setRunsAsService(true);
18362
+ const outcome = await installService(deps);
18363
+ for (const line of outcome.lines) write(`${INDENT}${line}`);
18364
+ if (!outcome.ok) {
18365
+ setRunsAsService(false);
18366
+ return false;
18367
+ }
18368
+ const deadline = now() + STARTUP_WAIT_MS;
18369
+ while (now() < deadline) {
18370
+ const state = readState();
18371
+ if (state?.socket) return true;
18372
+ await sleep5(250);
18373
+ }
18374
+ write(
18375
+ `${INDENT}The service is installed but Cabane hasn't come up yet. ${cabane("service status")} and ${cabane("logs")} say why.`
18376
+ );
18377
+ return false;
18378
+ }
18379
+ async function serviceInstall(deps = {}) {
18380
+ if (!await installAndStartService(deps)) {
18381
+ process.exitCode = 1;
18382
+ return;
18383
+ }
18384
+ write(
18385
+ INDENT + tick(
18386
+ "Cabane runs as a service. It starts at boot, restarts if it stops, and updates itself between turns."
18387
+ )
18388
+ );
18389
+ write(`${INDENT} Status: ${cabane("service status")} Logs: ${cabane("logs")}`);
18390
+ }
18391
+ async function serviceUninstall(deps = {}) {
18392
+ const outcome = await uninstallService(deps);
18393
+ for (const line of outcome.lines) write(`${INDENT}${line}`);
18394
+ if (outcome.lines.length > 0) return;
18395
+ setRunsAsService(false);
18396
+ write(
18397
+ INDENT + tick("Removed the service. Cabane won't start at boot, and it isn't running now.")
18398
+ );
18399
+ write(`${INDENT} Start it again with ${cabane("start")}`);
18400
+ }
18401
+ async function serviceStatus(deps = {}) {
18402
+ const state = await serviceState(deps);
18403
+ for (const line of serviceRows(state)) write(line);
18404
+ for (const line of updateRows(readUpdateState(), safeConfig()?.update?.auto, {
18405
+ service: state.installed
18406
+ }))
18407
+ write(line);
18408
+ if (state.installed && state.state !== "running") process.exitCode = 1;
18409
+ }
18410
+ var LABEL_WIDTH = 13;
18411
+ var row = (label, value) => ` ${label.padEnd(LABEL_WIDTH)}${value}`;
18412
+ function serviceRows(state) {
18413
+ if (!state.installed) {
18414
+ return [
18415
+ row(
18416
+ "Service",
18417
+ state.platform ? `not installed \u2014 ${cabane("service install")} starts Cabane at boot` : "not available on this system"
18418
+ )
18419
+ ];
18420
+ }
18421
+ const via = state.platform === "systemd" ? "systemd" : "launchd";
18422
+ const rows = [
18423
+ row(
18424
+ "Service",
18425
+ state.state === "running" ? `running (${via}${state.pid ? `, pid ${state.pid}` : ""})` : `installed, not running (${via}: ${state.state})`
18426
+ )
18427
+ ];
18428
+ if (!state.nodeOk) {
18429
+ rows.push(
18430
+ ` ! The service's Node at ${tildePath(state.node ?? "")} is gone. Run ${cabane("service install")} again.`
18431
+ );
18432
+ }
18433
+ return rows;
18434
+ }
18435
+ function updateRows(state, auto, opts = {}) {
18436
+ const rows = [];
18437
+ if (opts.version !== false) rows.push(row("Version", COMPANION_VERSION));
18438
+ if (state.server) {
18439
+ rows.push(
18440
+ row(
18441
+ "Cabane wants",
18442
+ `${state.server.latest} (the latest), ${state.server.minimum} or newer to run turns`
18443
+ )
18444
+ );
18445
+ }
18446
+ rows.push(
18447
+ row(
18448
+ "Auto-update",
18449
+ !opts.service ? `off \u2014 runs with the service: ${cabane("service install")}` : auto === true ? "on \u2014 updates install between turns and roll back if they fail" : `off \u2014 ${cabane("update --auto on")} turns it on`
18450
+ )
18451
+ );
18452
+ const last = state.lastResult;
18453
+ if (last) {
18454
+ const when = new Date(last.at).toLocaleDateString("en-GB", { day: "numeric", month: "short" });
18455
+ const why = last.reason ? `: ${last.reason}` : "";
18456
+ rows.push(
18457
+ row(
18458
+ "Last update",
18459
+ last.kind === "updated" ? `${last.from} \u2192 ${last.to} on ${when}` : last.kind === "rolled_back" ? `${last.to} rolled back to ${last.from} on ${when}${why}` : `${last.to} failed on ${when}${why}`
18460
+ )
18461
+ );
18462
+ }
18463
+ return rows;
18464
+ }
18465
+ function safeConfig() {
18466
+ try {
18467
+ return loadConfig();
18468
+ } catch {
18469
+ return null;
18470
+ }
18471
+ }
18472
+ function setRunsAsService(on) {
18473
+ const cfg = safeConfig();
18474
+ if (!cfg) return;
18475
+ const { serviceDeclined: _declined, ...rest } = cfg;
18476
+ saveConfig({ ...rest, update: { auto: on } });
18477
+ }
18478
+
17095
18479
  // src/commands/start.ts
17096
18480
  var FORCE_EXIT_MS = 4e3;
17097
18481
  var MAX_CODES = 3;
@@ -17116,6 +18500,10 @@ async function startScript(opts, interactive) {
17116
18500
  await startSupervised(opts, interactive, daemon);
17117
18501
  return;
17118
18502
  }
18503
+ if (serviceInstalled()) {
18504
+ await startTheService();
18505
+ return;
18506
+ }
17119
18507
  await startForPerson(opts, interactive);
17120
18508
  }
17121
18509
  async function surveyAgents(cfg, deps = {}) {
@@ -17499,7 +18887,7 @@ function openLink(link, spinner) {
17499
18887
  `${INDENT}Couldn't open a browser here. Open this link on any device: ${link}`
17500
18888
  );
17501
18889
  try {
17502
- const child = spawn9(opener, [link], { stdio: "ignore", detached: true });
18890
+ const child = spawn10(opener, [link], { stdio: "ignore", detached: true });
17503
18891
  child.once("error", failed);
17504
18892
  child.once("exit", (code) => {
17505
18893
  if (code !== 0) failed();
@@ -17510,32 +18898,67 @@ function openLink(link, spinner) {
17510
18898
  }
17511
18899
  }
17512
18900
  async function handOffToBackground(runtime, ctx2) {
18901
+ const { asService } = ctx2.justPaired ? await askHowToRun() : { asService: false };
17513
18902
  await runtime.stop();
17514
- const outcome = await startDaemon({ report: "failures", ...ctx2 });
17515
- if (!outcome.started) {
17516
- return;
18903
+ if (asService) {
18904
+ if (!await installAndStartService()) {
18905
+ write(`${INDENT}Running it in the background instead.`);
18906
+ if (!(await startDaemon({ report: "failures", ...ctx2 })).started) return;
18907
+ }
18908
+ } else {
18909
+ const outcome = await startDaemon({ report: "failures", ...ctx2 });
18910
+ if (!outcome.started) {
18911
+ return;
18912
+ }
17517
18913
  }
18914
+ const supervised = asService && serviceInstalled();
17518
18915
  for (const label of ctx2.connected) write(INDENT + tick(`${label} connected.`));
17519
18916
  for (const runtime2 of ctx2.signedOut) {
17520
18917
  write(
17521
18918
  `${INDENT}! ${HARNESS_LABELS[runtime2]} is connected here but signed out. Run ${loginCommand(runtime2)} to use it again.`
17522
18919
  );
17523
18920
  }
18921
+ const where = supervised ? "as a service, and starts at boot" : "in the background";
17524
18922
  write(
17525
18923
  INDENT + tick(
17526
- ctx2.justPaired ? "Cabane is running in the background. You can close this window." : "Cabane is running in the background."
18924
+ ctx2.justPaired ? `Cabane is running ${where}. You can close this window.` : `Cabane is running ${where}.`
17527
18925
  )
17528
18926
  );
17529
18927
  write(` Stop: ${cabane("stop")} Logs: ${cabane("logs")}`);
17530
18928
  if (ctx2.justPaired) {
17531
- blank();
17532
- write(
17533
- `${INDENT}! It won't restart on its own after a reboot \u2014 run ${cabane("start")} again.`
17534
- );
18929
+ if (!supervised) {
18930
+ blank();
18931
+ write(
18932
+ `${INDENT}! It won't restart on its own after a reboot \u2014 run ${cabane("start")} again, or ${cabane("service install")} to start it at boot.`
18933
+ );
18934
+ }
17535
18935
  blank();
17536
18936
  write(`${INDENT}All set on this side. Finish up in Cabane.`);
17537
18937
  }
17538
18938
  }
18939
+ async function askHowToRun(deps = {}) {
18940
+ const ask = deps.confirm ?? confirm;
18941
+ const cfg = safeLoadConfig();
18942
+ if (!cfg || !servicePlatform() || cfg.serviceDeclined || serviceInstalled())
18943
+ return { asService: false };
18944
+ blank();
18945
+ const asService = await ask(
18946
+ "Keep Cabane running and up to date on this computer? It starts at boot, restarts if it stops, and updates itself between turns."
18947
+ );
18948
+ if (!asService) saveConfig({ ...cfg, serviceDeclined: true });
18949
+ blank();
18950
+ return { asService };
18951
+ }
18952
+ async function startTheService() {
18953
+ const outcome = await startService();
18954
+ for (const line of outcome.lines) write(`${INDENT}${line}`);
18955
+ if (!outcome.ok) {
18956
+ process.exitCode = 1;
18957
+ return;
18958
+ }
18959
+ write(INDENT + tick("Cabane runs as a service here, and is starting."));
18960
+ write(` Status: ${cabane("service status")} Logs: ${cabane("logs")}`);
18961
+ }
17539
18962
  async function runAttached(runtime) {
17540
18963
  await new Promise((resolve2) => {
17541
18964
  let shuttingDown = false;
@@ -17607,7 +19030,7 @@ async function pair(opts = {}, deps = {}) {
17607
19030
  }
17608
19031
 
17609
19032
  // src/cli.ts
17610
- import { readFileSync as readFileSync14 } from "fs";
19033
+ import { readFileSync as readFileSync18 } from "fs";
17611
19034
 
17612
19035
  // src/commands/logs.ts
17613
19036
  init_cabane_command();
@@ -17691,18 +19114,18 @@ async function logs(opts = {}) {
17691
19114
  // src/commands/status.ts
17692
19115
  init_bundled_binaries();
17693
19116
  init_cabane_command();
17694
- import { readdirSync as readdirSync6, realpathSync as realpathSync5, existsSync as existsSync17 } from "fs";
17695
- import { homedir as homedir7 } from "os";
17696
- import { dirname as dirname9, join as join24 } from "path";
19117
+ import { readdirSync as readdirSync7, realpathSync as realpathSync7, existsSync as existsSync20 } from "fs";
19118
+ import { homedir as homedir8 } from "os";
19119
+ import { dirname as dirname14, join as join29 } from "path";
17697
19120
  init_config();
17698
19121
  init_sandbox_policy();
17699
19122
  init_secrets();
17700
19123
  init_term();
17701
19124
  init_transcript_writer();
17702
19125
  init_version2();
17703
- var LABEL_WIDTH = 13;
17704
- function row(label, value) {
17705
- return ` ${label.padEnd(LABEL_WIDTH)}${value}`;
19126
+ var LABEL_WIDTH2 = 13;
19127
+ function row2(label, value) {
19128
+ return ` ${label.padEnd(LABEL_WIDTH2)}${value}`;
17706
19129
  }
17707
19130
  async function status() {
17708
19131
  const out = (line) => void process.stdout.write(`${line}
@@ -17720,29 +19143,36 @@ async function status() {
17720
19143
  process.exitCode = 1;
17721
19144
  return;
17722
19145
  }
17723
- out(row("Paired with", `${cfg.baseUrl}${cfg.deviceLabel ? ` as ${cfg.deviceLabel}` : ""}`));
19146
+ out(row2("Paired with", `${cfg.baseUrl}${cfg.deviceLabel ? ` as ${cfg.deviceLabel}` : ""}`));
17724
19147
  const running = await liveState();
17725
19148
  let harnesses = null;
17726
19149
  if (running) {
17727
19150
  const mode = running.daemon ? "in the background" : "in a terminal";
17728
19151
  const uptime = formatUptime(running.startedAt);
17729
- out(row("Running", `${mode} (pid ${running.pid}${uptime ? `, up ${uptime}` : ""})`));
19152
+ out(row2("Running", `${mode} (pid ${running.pid}${uptime ? `, up ${uptime}` : ""})`));
17730
19153
  harnesses = await runningHarnesses(running);
17731
19154
  } else {
17732
- out(row("Running", `no \u2014 start it with ${cabane("start")}`));
17733
- }
19155
+ out(row2("Running", `no \u2014 start it with ${cabane("start")}`));
19156
+ }
19157
+ const service2 = await serviceState();
19158
+ for (const line of serviceRows(service2)) out(line);
19159
+ for (const line of updateRows(readUpdateState(), cfg.update?.auto, {
19160
+ version: false,
19161
+ service: service2.installed
19162
+ }))
19163
+ out(line);
17734
19164
  for (const line of await agentLines(cfg, harnesses)) out(line);
17735
- out(row("Logs", `${cabane("logs")} (${tildePath(companionLogPath())})`));
17736
- out(row("Transcripts", transcriptsLine()));
19165
+ out(row2("Logs", `${cabane("logs")} (${tildePath(companionLogPath())})`));
19166
+ out(row2("Transcripts", transcriptsLine()));
17737
19167
  const names = loadSecretStoreTolerant().names();
17738
19168
  out(
17739
- names.length > 0 ? row("Secrets", `${names.length} declared (${names.join(", ")})`) : row(
19169
+ names.length > 0 ? row2("Secrets", `${names.length} declared (${names.join(", ")})`) : row2(
17740
19170
  "Secrets",
17741
19171
  "none declared \u2014 add `${VAR}` values to ~/.cabane/secrets.json for MCP servers that need them"
17742
19172
  )
17743
19173
  );
17744
19174
  if (cfg.prepareHook) {
17745
- out(row("Prepare hook", `${cfg.prepareHook.command} (device-level, all agents)`));
19175
+ out(row2("Prepare hook", `${cfg.prepareHook.command} (device-level, all agents)`));
17746
19176
  }
17747
19177
  for (const line of sandboxStatusLines(cfg)) out(line);
17748
19178
  const overrides2 = Object.keys(cfg.agents ?? {});
@@ -17752,10 +19182,10 @@ function versionLine() {
17752
19182
  const install = companionInstall();
17753
19183
  const own = installedCommandPath({ install });
17754
19184
  if (install === "script") {
17755
- return `Cabane ${COMPANION_VERSION} \xB7 installed by the install script (${tildePath(join24(homedir7(), ".cabane"))})`;
19185
+ return `Cabane ${COMPANION_VERSION} \xB7 installed by the install script (${tildePath(join29(homedir8(), ".cabane"))})`;
17756
19186
  }
17757
19187
  if (own)
17758
- return `Cabane ${COMPANION_VERSION} \xB7 installed with npm (${tildePath(dirname9(dirname9(own)))})`;
19188
+ return `Cabane ${COMPANION_VERSION} \xB7 installed with npm (${tildePath(dirname14(dirname14(own)))})`;
17759
19189
  return `Cabane ${COMPANION_VERSION}`;
17760
19190
  }
17761
19191
  function otherInstallLines(running) {
@@ -17772,14 +19202,14 @@ function otherInstallLines(running) {
17772
19202
  function otherInstall(install, own) {
17773
19203
  const real = (p) => {
17774
19204
  try {
17775
- return realpathSync5(p);
19205
+ return realpathSync7(p);
17776
19206
  } catch {
17777
19207
  return null;
17778
19208
  }
17779
19209
  };
17780
19210
  if (install === "npm") {
17781
- const script = join24(
17782
- homedir7(),
19211
+ const script = join29(
19212
+ homedir8(),
17783
19213
  ".cabane",
17784
19214
  "node",
17785
19215
  "lib",
@@ -17787,12 +19217,12 @@ function otherInstall(install, own) {
17787
19217
  "@cabane",
17788
19218
  "companion"
17789
19219
  );
17790
- return existsSync17(script) ? join24(homedir7(), ".cabane") : null;
19220
+ return existsSync20(script) ? join29(homedir8(), ".cabane") : null;
17791
19221
  }
17792
19222
  const found = firstOnPath("cabane-companion", process.env.PATH);
17793
19223
  if (!found) return null;
17794
- const ownAlias = own ? real(join24(dirname9(own), "cabane-companion")) : null;
17795
- return real(found) === ownAlias ? null : dirname9(found);
19224
+ const ownAlias = own ? real(join29(dirname14(own), "cabane-companion")) : null;
19225
+ return real(found) === ownAlias ? null : dirname14(found);
17796
19226
  }
17797
19227
  async function liveState() {
17798
19228
  const running = readLiveRuntimeState();
@@ -17819,18 +19249,18 @@ async function agentLines(cfg, running) {
17819
19249
  const label = HARNESS_LABELS[runtime];
17820
19250
  const connected = runtime === "codex" ? !!cfg.codex && cfg.codex.enabled !== false : !!cfg.claudeCode && cfg.claudeCode.enabled !== false;
17821
19251
  if (!connected) {
17822
- lines2.push(row(label, "not connected"));
19252
+ lines2.push(row2(label, "not connected"));
17823
19253
  continue;
17824
19254
  }
17825
19255
  const fromRunning = running?.find((h) => h.runtime === runtime);
17826
19256
  const signIn = fromRunning ? { status: fromRunning.signIn, version: fromRunning.version } : await localSignIn(runtime);
17827
- lines2.push(row(label, signInText(runtime, signIn.status, signIn.version)));
19257
+ lines2.push(row2(label, signInText(runtime, signIn.status, signIn.version)));
17828
19258
  }
17829
19259
  if (cfg.opencode?.serverUrl) {
17830
19260
  const fromRunning = running?.find((h) => h.runtime === "opencode");
17831
19261
  const answering = fromRunning ? fromRunning.state === "exposed" : null;
17832
19262
  lines2.push(
17833
- row(
19263
+ row2(
17834
19264
  HARNESS_LABELS.opencode,
17835
19265
  `${cfg.opencode.serverUrl}${answering === false ? " \u2014 not answering" : ""}`
17836
19266
  )
@@ -17886,7 +19316,7 @@ function transcriptsLine() {
17886
19316
  let count = 0;
17887
19317
  let newest;
17888
19318
  try {
17889
- const files = readdirSync6(dir2).filter((f) => f.endsWith(".jsonl")).sort();
19319
+ const files = readdirSync7(dir2).filter((f) => f.endsWith(".jsonl")).sort();
17890
19320
  count = files.length;
17891
19321
  newest = files[files.length - 1];
17892
19322
  } catch {
@@ -17906,61 +19336,107 @@ function formatUptime(startedAt) {
17906
19336
  return remMin ? `${hr}h${remMin}m` : `${hr}h`;
17907
19337
  }
17908
19338
 
17909
- // src/commands/stop.ts
17910
- var TERM_GRACE_MS = 6e3;
17911
- var POLL_INTERVAL_MS2 = 150;
17912
- async function stop(deps = {}) {
17913
- const readState = deps.readState ?? readLiveRuntimeState;
17914
- const verify = deps.verify ?? ((s) => verifyRuntime(s));
17915
- const kill = deps.kill ?? ((pid2, signal) => process.kill(pid2, signal));
17916
- const sleep5 = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
17917
- const now = deps.now ?? (() => Date.now());
17918
- const state = readState();
17919
- if (!state) {
17920
- process.stdout.write("companion: not running (nothing to stop).\n");
19339
+ // src/commands/update.ts
19340
+ init_config();
19341
+ init_term();
19342
+ init_version2();
19343
+ init_cabane_command();
19344
+ var UPDATE_TIMEOUT_MS = 20 * 6e4;
19345
+ async function update(opts = {}) {
19346
+ const running = readLiveRuntimeState();
19347
+ if (opts.auto) {
19348
+ const auto = opts.auto === "on";
19349
+ if (auto && !serviceInstalled()) {
19350
+ write(
19351
+ `${INDENT}Cabane updates itself only when it runs as a service. Run ${cabane("service install")} to run it as one, with updates on.`
19352
+ );
19353
+ process.exitCode = 1;
19354
+ return;
19355
+ }
19356
+ if (running?.socket) {
19357
+ const r = await controlRequest(running.socket, { cmd: "update", auto });
19358
+ write(`${INDENT}${r.ok ? tick(r.message) : r.message}`);
19359
+ return;
19360
+ }
19361
+ const cfg2 = loadConfig();
19362
+ if (!cfg2) {
19363
+ write(`${INDENT}This computer isn't set up yet. Run ${cabane("start")} first.`);
19364
+ process.exitCode = 1;
19365
+ return;
19366
+ }
19367
+ saveConfig({ ...cfg2, update: { auto } });
19368
+ write(
19369
+ INDENT + tick(
19370
+ auto ? "Cabane will keep itself up to date. Updates install between turns and roll back if they fail." : "Cabane won't update itself."
19371
+ )
19372
+ );
17921
19373
  return;
17922
19374
  }
17923
- if (await verify(state) === "stale") {
17924
- clearRuntimeState();
17925
- process.stdout.write("companion: not running (stale marker swept).\n");
19375
+ if (running?.socket) {
19376
+ const r = await controlRequest(
19377
+ running.socket,
19378
+ { cmd: "update" },
19379
+ UPDATE_TIMEOUT_MS
19380
+ );
19381
+ write(`${INDENT}${r.ok ? tick(r.message) : r.message}`);
19382
+ if (!r.ok) process.exitCode = 1;
17926
19383
  return;
17927
19384
  }
17928
- const { pid } = state;
17929
- process.stdout.write(`companion: stopping (pid ${pid})\u2026
17930
- `);
17931
- try {
17932
- kill(pid, "SIGTERM");
17933
- } catch {
17934
- clearRuntimeState();
17935
- process.stdout.write("companion: already stopped.\n");
19385
+ const latest = readUpdateState().server?.latest;
19386
+ if (!latest) {
19387
+ write(
19388
+ `${INDENT}Cabane hasn't said which version is latest yet. Run ${cabane("start")}, and it updates from there.`
19389
+ );
19390
+ process.exitCode = 1;
17936
19391
  return;
17937
19392
  }
17938
- const deadline = now() + TERM_GRACE_MS;
17939
- while (now() < deadline) {
17940
- if (!isAlive(kill, pid)) {
17941
- process.stdout.write("companion: stopped.\n");
17942
- return;
17943
- }
17944
- await sleep5(POLL_INTERVAL_MS2);
19393
+ if (compareVersions(latest, COMPANION_VERSION) <= 0) {
19394
+ write(INDENT + tick(`Cabane ${COMPANION_VERSION} is up to date.`));
19395
+ return;
17945
19396
  }
17946
- process.stdout.write(
17947
- `companion: didn't exit within ${TERM_GRACE_MS / 1e3}s, sending SIGKILL.
17948
- `
19397
+ const cfg = loadConfig();
19398
+ const harnesses = cfg ? [
19399
+ ...isClaudeCodeConnected(cfg) ? ["claude-code"] : [],
19400
+ ...isCodexEnabled(cfg) ? ["codex"] : []
19401
+ ] : [];
19402
+ write(
19403
+ `${INDENT}Installing Cabane ${latest}. It brings its own copies of the agents, so this can take a few minutes.`
17949
19404
  );
17950
- try {
17951
- kill(pid, "SIGKILL");
17952
- } catch {
17953
- }
17954
- clearRuntimeState();
17955
- process.stdout.write("companion: force-stopped.\n");
17956
- }
17957
- function isAlive(kill, pid) {
17958
- try {
17959
- kill(pid, 0);
17960
- return true;
17961
- } catch {
17962
- return false;
19405
+ const outcome = await stageVersion(latest, harnesses);
19406
+ if (!outcome.ok) {
19407
+ if (outcome.unpublished) {
19408
+ write(`${INDENT}Cabane ${latest} isn't published yet. Try again later.`);
19409
+ process.exitCode = 1;
19410
+ return;
19411
+ }
19412
+ updateUpdateState((s) => ({
19413
+ ...s,
19414
+ lastResult: {
19415
+ kind: "failed",
19416
+ from: COMPANION_VERSION,
19417
+ to: latest,
19418
+ at: (/* @__PURE__ */ new Date()).toISOString(),
19419
+ reason: outcome.reason
19420
+ }
19421
+ }));
19422
+ write(`${INDENT}Couldn't update to ${latest}: ${outcome.reason}.`);
19423
+ process.exitCode = 1;
19424
+ return;
17963
19425
  }
19426
+ const fromRoot = currentRoot() ?? ownPackageRoot();
19427
+ updateUpdateState((s) => ({
19428
+ ...s,
19429
+ pending: {
19430
+ from: COMPANION_VERSION,
19431
+ fromRoot,
19432
+ to: latest,
19433
+ at: (/* @__PURE__ */ new Date()).toISOString(),
19434
+ boots: 0
19435
+ }
19436
+ }));
19437
+ writeBootGuard();
19438
+ pointCurrentAt(outcome.root);
19439
+ write(INDENT + tick(`Updated to ${latest}. Start it with ${cabane("start")}`));
17964
19440
  }
17965
19441
 
17966
19442
  // src/commands/transcript.ts
@@ -17968,8 +19444,8 @@ init_cabane_command();
17968
19444
  init_errors();
17969
19445
  init_src();
17970
19446
  init_transcript_writer();
17971
- import { existsSync as existsSync18, readFileSync as readFileSync13, readdirSync as readdirSync7 } from "fs";
17972
- import { isAbsolute, join as join25 } from "path";
19447
+ import { existsSync as existsSync21, readFileSync as readFileSync17, readdirSync as readdirSync8 } from "fs";
19448
+ import { isAbsolute, join as join30 } from "path";
17973
19449
  async function transcript(opts = {}) {
17974
19450
  const dir2 = transcriptsDir();
17975
19451
  if (opts.follow) {
@@ -17986,7 +19462,7 @@ async function transcript(opts = {}) {
17986
19462
  process.stdout.write(emptyMessage(dir2));
17987
19463
  return;
17988
19464
  }
17989
- process.stdout.write(renderFile(join25(dir2, newest)) + "\n");
19465
+ process.stdout.write(renderFile(join30(dir2, newest)) + "\n");
17990
19466
  return;
17991
19467
  }
17992
19468
  printList(dir2);
@@ -18069,7 +19545,7 @@ function isComplete(content) {
18069
19545
  async function followTranscripts(dir2) {
18070
19546
  const follower = new TranscriptFollower({
18071
19547
  listFiles: () => listFiles(dir2),
18072
- read: (f) => readFileSync13(join25(dir2, f), "utf8"),
19548
+ read: (f) => readFileSync17(join30(dir2, f), "utf8"),
18073
19549
  write: (s) => process.stdout.write(s),
18074
19550
  // CSI: cursor up `n` lines, then erase from cursor to end of screen.
18075
19551
  clearLines: (n) => process.stdout.write(`\x1B[${n}A\x1B[0J`),
@@ -18094,7 +19570,7 @@ async function followTranscripts(dir2) {
18094
19570
  }
18095
19571
  function listFiles(dir2) {
18096
19572
  try {
18097
- return readdirSync7(dir2).filter((f) => f.endsWith(".jsonl")).sort().reverse();
19573
+ return readdirSync8(dir2).filter((f) => f.endsWith(".jsonl")).sort().reverse();
18098
19574
  } catch {
18099
19575
  return [];
18100
19576
  }
@@ -18109,7 +19585,7 @@ function printList(dir2) {
18109
19585
 
18110
19586
  `);
18111
19587
  for (const f of files.slice(0, 20)) {
18112
- const { meta, outcome } = peek(join25(dir2, f));
19588
+ const { meta, outcome } = peek(join30(dir2, f));
18113
19589
  const when = fmtTime(rec(meta)?.ts);
18114
19590
  const ws = str2(rec(meta)?.workspaceSlug);
18115
19591
  const o = rec(outcome);
@@ -18131,7 +19607,7 @@ function peek(path) {
18131
19607
  let meta;
18132
19608
  let outcome;
18133
19609
  try {
18134
- for (const line of readFileSync13(path, "utf8").split("\n")) {
19610
+ for (const line of readFileSync17(path, "utf8").split("\n")) {
18135
19611
  if (!line.trim()) continue;
18136
19612
  const o = safeParse(line);
18137
19613
  const t = str2(rec(o)?.type);
@@ -18144,13 +19620,13 @@ function peek(path) {
18144
19620
  }
18145
19621
  function resolveTarget(dir2, target) {
18146
19622
  if (isAbsolute(target) || target.includes("/")) {
18147
- if (existsSync18(target)) return target;
19623
+ if (existsSync21(target)) return target;
18148
19624
  throw new CompanionError(`no transcript at ${target}.`);
18149
19625
  }
18150
- const exact = join25(dir2, target);
18151
- if (existsSync18(exact)) return exact;
19626
+ const exact = join30(dir2, target);
19627
+ if (existsSync21(exact)) return exact;
18152
19628
  const matches = listFiles(dir2).filter((f) => f.includes(target));
18153
- if (matches.length === 1) return join25(dir2, matches[0]);
19629
+ if (matches.length === 1) return join30(dir2, matches[0]);
18154
19630
  if (matches.length === 0) {
18155
19631
  throw new CompanionError(
18156
19632
  `no transcript matching "${target}" in ${dir2}. Run \`${cabane("transcript")}\` to list them.`
@@ -18164,7 +19640,7 @@ function resolveTarget(dir2, target) {
18164
19640
  function renderFile(path) {
18165
19641
  let content;
18166
19642
  try {
18167
- content = readFileSync13(path, "utf8");
19643
+ content = readFileSync17(path, "utf8");
18168
19644
  } catch (err) {
18169
19645
  throw new CompanionError(
18170
19646
  `couldn't read ${path}: ${err instanceof Error ? err.message : String(err)}`
@@ -18324,7 +19800,7 @@ program.command("pair").description(
18324
19800
  });
18325
19801
  });
18326
19802
  program.command("write-paired-config", { hidden: true }).description("persist an already-completed device enrollment payload from stdin.").action(() => {
18327
- writeCompletedPairing(readFileSync14(0, "utf8"));
19803
+ writeCompletedPairing(readFileSync18(0, "utf8"));
18328
19804
  });
18329
19805
  program.command("start").description("pair this device if needed, connect an agent on it, and run in the background.").option("--foreground", "run attached in this terminal (for terminals and supervisors)").option("--daemon", "run in the background (the default; kept for compatibility)").option("--server <url>", "the cabane instance to pair with, if this device isn\u2019t paired yet").addOption(
18330
19806
  new Option("--log-level <level>", "info (default), warn, or debug").choices([
@@ -18348,6 +19824,21 @@ program.command("start").description("pair this device if needed, connect an age
18348
19824
  program.command("connect").description("connect an agent on this machine to Cabane (claude-code, codex, opencode).").argument("<harness>", "claude-code, codex, or opencode").option("--url <url>", "the `opencode serve` URL (opencode only)").action(async (harness, opts) => {
18349
19825
  await connect2(harness, { ...opts.url !== void 0 ? { serverUrl: opts.url } : {} });
18350
19826
  });
19827
+ program.command("update").description(
19828
+ "update to the latest version now, or set whether Cabane keeps itself up to date (--auto on|off)."
19829
+ ).addOption(new Option("--auto <setting>", "on or off").choices(["on", "off"])).action(async (opts) => {
19830
+ await update(opts.auto ? { auto: opts.auto } : {});
19831
+ });
19832
+ var service = program.command("service").description("run Cabane as a service that starts at boot and restarts if it stops.");
19833
+ service.command("install").description("install the service and start it (systemd on Linux, launchd on macOS).").action(async () => {
19834
+ await serviceInstall();
19835
+ });
19836
+ service.command("uninstall").description("stop the service and remove it.").action(async () => {
19837
+ await serviceUninstall();
19838
+ });
19839
+ service.command("status").description("show the service's state, this version, and how updates are going.").action(async () => {
19840
+ await serviceStatus();
19841
+ });
18351
19842
  program.command("stop").description("stop a running companion (SIGTERM, then force-kill after a timeout).").action(async () => {
18352
19843
  await stop();
18353
19844
  });