codeharbor 0.1.18 → 0.1.20

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/.env.example CHANGED
@@ -63,6 +63,11 @@ CLI_COMPAT_PRESERVE_WHITESPACE=true
63
63
  CLI_COMPAT_DISABLE_REPLY_CHUNK_SPLIT=false
64
64
  CLI_COMPAT_PROGRESS_THROTTLE_MS=300
65
65
  CLI_COMPAT_FETCH_MEDIA=true
66
+ # Optional audio transcription for Matrix m.audio attachments.
67
+ CLI_COMPAT_TRANSCRIBE_AUDIO=false
68
+ CLI_COMPAT_AUDIO_TRANSCRIBE_MODEL=gpt-4o-mini-transcribe
69
+ CLI_COMPAT_AUDIO_TRANSCRIBE_TIMEOUT_MS=120000
70
+ CLI_COMPAT_AUDIO_TRANSCRIBE_MAX_CHARS=6000
66
71
  # Optional JSONL output path for executed prompt recording (for replay benchmarking).
67
72
  CLI_COMPAT_RECORD_PATH=
68
73
 
package/README.md CHANGED
@@ -93,6 +93,8 @@ Notes:
93
93
  - Service commands auto-elevate with `sudo` when root privileges are required.
94
94
  - `codeharbor service install --with-admin` and `install-linux-easy.sh --enable-admin-service` now install
95
95
  `/etc/sudoers.d/codeharbor-restart` for non-root service users, so Admin UI restart actions work out-of-box.
96
+ - `npm install -g codeharbor@latest` now performs best-effort restart for active `codeharbor(.service)` units on Linux
97
+ so upgrades take effect immediately (set `CODEHARBOR_SKIP_POSTINSTALL_RESTART=1` to disable).
96
98
  - If your environment blocks interactive `sudo`, use explicit fallback:
97
99
  - `sudo <node-bin> <codeharbor-cli-script> service ...`
98
100
 
@@ -458,6 +460,14 @@ To make IM behavior closer to local `codex` CLI interaction, enable:
458
460
  - lower update throttle for near-real-time progress
459
461
  - `CLI_COMPAT_FETCH_MEDIA=true|false`
460
462
  - download Matrix `mxc://` media (image) to temp file and pass it to codex via `--image`
463
+ - `CLI_COMPAT_TRANSCRIBE_AUDIO=true|false`
464
+ - download Matrix `m.audio` attachments and transcribe them into prompt context
465
+ - `CLI_COMPAT_AUDIO_TRANSCRIBE_MODEL`
466
+ - OpenAI transcription model (default `gpt-4o-mini-transcribe`)
467
+ - `CLI_COMPAT_AUDIO_TRANSCRIBE_TIMEOUT_MS`
468
+ - timeout for each audio transcription request
469
+ - `CLI_COMPAT_AUDIO_TRANSCRIBE_MAX_CHARS`
470
+ - max transcript length appended to prompt for one attachment
461
471
  - `CLI_COMPAT_RECORD_PATH=/abs/path/records.jsonl`
462
472
  - append executed prompts as JSONL for replay benchmarking
463
473
 
@@ -501,6 +511,15 @@ When image attachments are present and `CLI_COMPAT_FETCH_MEDIA=true`, CodeHarbor
501
511
  3. best-effort cleanup temp files after the request
502
512
  4. optional prompt record append (`CLI_COMPAT_RECORD_PATH`) for deterministic replay input
503
513
 
514
+ When audio attachments are present and both `CLI_COMPAT_FETCH_MEDIA=true` and `CLI_COMPAT_TRANSCRIBE_AUDIO=true`, CodeHarbor will:
515
+
516
+ 1. download `m.audio` media to a temp file
517
+ 2. call OpenAI audio transcription API and append transcript to `[audio_transcripts]` prompt block
518
+ 3. continue request even if transcription fails (warn log + no transcript)
519
+ 4. best-effort cleanup temp files after the request
520
+
521
+ `OPENAI_API_KEY` is required only when audio transcription is enabled.
522
+
504
523
  ## Replay Benchmark
505
524
 
506
525
  Replay recorded prompts directly against codex CLI to quantify drift and latency:
package/dist/cli.js CHANGED
@@ -26,7 +26,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
26
26
  // src/cli.ts
27
27
  var import_node_child_process7 = require("child_process");
28
28
  var import_node_fs11 = __toESM(require("fs"));
29
- var import_node_path14 = __toESM(require("path"));
29
+ var import_node_path15 = __toESM(require("path"));
30
30
  var import_commander = require("commander");
31
31
 
32
32
  // src/app.ts
@@ -353,6 +353,19 @@ var ADMIN_CONSOLE_HTML = `<!doctype html>
353
353
  <input id="global-cli-throttle" type="number" min="0" />
354
354
  </label>
355
355
  <label class="checkbox"><input id="global-cli-fetch-media" type="checkbox" /><span>Fetch media attachments</span></label>
356
+ <label class="checkbox"><input id="global-cli-transcribe-audio" type="checkbox" /><span>Transcribe audio attachments</span></label>
357
+ <label class="field">
358
+ <span class="field-label">Audio transcribe model</span>
359
+ <input id="global-cli-audio-model" type="text" />
360
+ </label>
361
+ <label class="field">
362
+ <span class="field-label">Audio transcribe timeout (ms)</span>
363
+ <input id="global-cli-audio-timeout" type="number" min="1" />
364
+ </label>
365
+ <label class="field">
366
+ <span class="field-label">Audio transcript max chars</span>
367
+ <input id="global-cli-audio-max-chars" type="number" min="1" />
368
+ </label>
356
369
  <label class="checkbox"><input id="global-agent-enabled" type="checkbox" /><span>Enable multi-agent workflow</span></label>
357
370
  <label class="field">
358
371
  <span class="field-label">Workflow auto-repair rounds</span>
@@ -687,6 +700,10 @@ var ADMIN_CONSOLE_HTML = `<!doctype html>
687
700
  document.getElementById("global-cli-disable-split").checked = Boolean(cliCompat.disableReplyChunkSplit);
688
701
  document.getElementById("global-cli-throttle").value = String(cliCompat.progressThrottleMs || 0);
689
702
  document.getElementById("global-cli-fetch-media").checked = Boolean(cliCompat.fetchMedia);
703
+ document.getElementById("global-cli-transcribe-audio").checked = Boolean(cliCompat.transcribeAudio);
704
+ document.getElementById("global-cli-audio-model").value = cliCompat.audioTranscribeModel || "gpt-4o-mini-transcribe";
705
+ document.getElementById("global-cli-audio-timeout").value = String(cliCompat.audioTranscribeTimeoutMs || 120000);
706
+ document.getElementById("global-cli-audio-max-chars").value = String(cliCompat.audioTranscribeMaxChars || 6000);
690
707
  document.getElementById("global-agent-enabled").checked = Boolean(agentWorkflow.enabled);
691
708
  document.getElementById("global-agent-repair-rounds").value = String(
692
709
  typeof agentWorkflow.autoRepairMaxRounds === "number" ? agentWorkflow.autoRepairMaxRounds : 1
@@ -728,7 +745,11 @@ var ADMIN_CONSOLE_HTML = `<!doctype html>
728
745
  preserveWhitespace: asBool("global-cli-whitespace"),
729
746
  disableReplyChunkSplit: asBool("global-cli-disable-split"),
730
747
  progressThrottleMs: asNumber("global-cli-throttle", 300),
731
- fetchMedia: asBool("global-cli-fetch-media")
748
+ fetchMedia: asBool("global-cli-fetch-media"),
749
+ transcribeAudio: asBool("global-cli-transcribe-audio"),
750
+ audioTranscribeModel: asText("global-cli-audio-model") || "gpt-4o-mini-transcribe",
751
+ audioTranscribeTimeoutMs: asNumber("global-cli-audio-timeout", 120000),
752
+ audioTranscribeMaxChars: asNumber("global-cli-audio-max-chars", 6000)
732
753
  },
733
754
  agentWorkflow: {
734
755
  enabled: asBool("global-agent-enabled"),
@@ -1966,6 +1987,44 @@ var AdminServer = class {
1966
1987
  envUpdates.CLI_COMPAT_FETCH_MEDIA = String(value);
1967
1988
  updatedKeys.push("cliCompat.fetchMedia");
1968
1989
  }
1990
+ if ("transcribeAudio" in compat) {
1991
+ const value = normalizeBoolean(compat.transcribeAudio, this.config.cliCompat.transcribeAudio);
1992
+ this.config.cliCompat.transcribeAudio = value;
1993
+ envUpdates.CLI_COMPAT_TRANSCRIBE_AUDIO = String(value);
1994
+ updatedKeys.push("cliCompat.transcribeAudio");
1995
+ }
1996
+ if ("audioTranscribeModel" in compat) {
1997
+ const value = normalizeString(
1998
+ compat.audioTranscribeModel,
1999
+ this.config.cliCompat.audioTranscribeModel,
2000
+ "cliCompat.audioTranscribeModel"
2001
+ );
2002
+ this.config.cliCompat.audioTranscribeModel = value || "gpt-4o-mini-transcribe";
2003
+ envUpdates.CLI_COMPAT_AUDIO_TRANSCRIBE_MODEL = this.config.cliCompat.audioTranscribeModel;
2004
+ updatedKeys.push("cliCompat.audioTranscribeModel");
2005
+ }
2006
+ if ("audioTranscribeTimeoutMs" in compat) {
2007
+ const value = normalizePositiveInt(
2008
+ compat.audioTranscribeTimeoutMs,
2009
+ this.config.cliCompat.audioTranscribeTimeoutMs,
2010
+ 1,
2011
+ Number.MAX_SAFE_INTEGER
2012
+ );
2013
+ this.config.cliCompat.audioTranscribeTimeoutMs = value;
2014
+ envUpdates.CLI_COMPAT_AUDIO_TRANSCRIBE_TIMEOUT_MS = String(value);
2015
+ updatedKeys.push("cliCompat.audioTranscribeTimeoutMs");
2016
+ }
2017
+ if ("audioTranscribeMaxChars" in compat) {
2018
+ const value = normalizePositiveInt(
2019
+ compat.audioTranscribeMaxChars,
2020
+ this.config.cliCompat.audioTranscribeMaxChars,
2021
+ 1,
2022
+ Number.MAX_SAFE_INTEGER
2023
+ );
2024
+ this.config.cliCompat.audioTranscribeMaxChars = value;
2025
+ envUpdates.CLI_COMPAT_AUDIO_TRANSCRIBE_MAX_CHARS = String(value);
2026
+ updatedKeys.push("cliCompat.audioTranscribeMaxChars");
2027
+ }
1969
2028
  }
1970
2029
  if ("agentWorkflow" in body) {
1971
2030
  const workflow = asObject(body.agentWorkflow, "agentWorkflow");
@@ -2621,6 +2680,7 @@ var MatrixChannel = class {
2621
2680
  splitReplies;
2622
2681
  preserveWhitespace;
2623
2682
  fetchMedia;
2683
+ transcribeAudio;
2624
2684
  client;
2625
2685
  handler = null;
2626
2686
  started = false;
@@ -2631,6 +2691,7 @@ var MatrixChannel = class {
2631
2691
  this.splitReplies = !config.cliCompat.disableReplyChunkSplit;
2632
2692
  this.preserveWhitespace = config.cliCompat.preserveWhitespace;
2633
2693
  this.fetchMedia = config.cliCompat.fetchMedia;
2694
+ this.transcribeAudio = config.cliCompat.transcribeAudio;
2634
2695
  this.client = (0, import_matrix_js_sdk.createClient)({
2635
2696
  baseUrl: config.matrixHomeserver,
2636
2697
  accessToken: config.matrixAccessToken,
@@ -2875,7 +2936,7 @@ var MatrixChannel = class {
2875
2936
  }
2876
2937
  const hydrated = await Promise.all(
2877
2938
  attachments.map(async (attachment, index) => {
2878
- if (attachment.kind !== "image" || !attachment.mxcUrl) {
2939
+ if (!shouldHydrateAttachment(attachment.kind, this.transcribeAudio) || !attachment.mxcUrl) {
2879
2940
  return attachment;
2880
2941
  }
2881
2942
  try {
@@ -3093,6 +3154,15 @@ function parseMxcUrl(mxcUrl) {
3093
3154
  const mediaId = stripped.slice(slashIndex + 1);
3094
3155
  return { serverName, mediaId };
3095
3156
  }
3157
+ function shouldHydrateAttachment(kind, transcribeAudio) {
3158
+ if (kind === "image") {
3159
+ return true;
3160
+ }
3161
+ if (kind === "audio") {
3162
+ return transcribeAudio;
3163
+ }
3164
+ return false;
3165
+ }
3096
3166
  function sanitizeFilename(value) {
3097
3167
  return value.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 80);
3098
3168
  }
@@ -3110,6 +3180,21 @@ function resolveFileExtension(fileName, mimeType) {
3110
3180
  if (mimeType === "image/webp") {
3111
3181
  return ".webp";
3112
3182
  }
3183
+ if (mimeType === "audio/mpeg") {
3184
+ return ".mp3";
3185
+ }
3186
+ if (mimeType === "audio/mp4" || mimeType === "audio/x-m4a") {
3187
+ return ".m4a";
3188
+ }
3189
+ if (mimeType === "audio/wav" || mimeType === "audio/x-wav") {
3190
+ return ".wav";
3191
+ }
3192
+ if (mimeType === "audio/ogg") {
3193
+ return ".ogg";
3194
+ }
3195
+ if (mimeType === "audio/flac") {
3196
+ return ".flac";
3197
+ }
3113
3198
  return ".bin";
3114
3199
  }
3115
3200
  function buildMatrixRichMessageContent(body, msgtype) {
@@ -3627,17 +3712,100 @@ function stringify(value) {
3627
3712
 
3628
3713
  // src/orchestrator.ts
3629
3714
  var import_async_mutex = require("async-mutex");
3630
- var import_promises4 = __toESM(require("fs/promises"));
3715
+ var import_promises5 = __toESM(require("fs/promises"));
3716
+
3717
+ // src/audio-transcriber.ts
3718
+ var import_promises3 = __toESM(require("fs/promises"));
3719
+ var import_node_path8 = __toESM(require("path"));
3720
+ var AudioTranscriber = class {
3721
+ enabled;
3722
+ apiKey;
3723
+ model;
3724
+ timeoutMs;
3725
+ maxChars;
3726
+ constructor(options) {
3727
+ this.enabled = options.enabled;
3728
+ this.apiKey = options.apiKey;
3729
+ this.model = options.model;
3730
+ this.timeoutMs = options.timeoutMs;
3731
+ this.maxChars = options.maxChars;
3732
+ }
3733
+ isEnabled() {
3734
+ return this.enabled;
3735
+ }
3736
+ async transcribeMany(attachments) {
3737
+ if (!this.enabled || attachments.length === 0) {
3738
+ return [];
3739
+ }
3740
+ if (!this.apiKey) {
3741
+ throw new Error(
3742
+ "Audio transcription is enabled but OPENAI_API_KEY is missing. Set OPENAI_API_KEY or disable CLI_COMPAT_TRANSCRIBE_AUDIO."
3743
+ );
3744
+ }
3745
+ const transcripts = [];
3746
+ for (const attachment of attachments) {
3747
+ const text = await this.transcribeOne(attachment);
3748
+ if (!text) {
3749
+ continue;
3750
+ }
3751
+ transcripts.push({
3752
+ name: attachment.name,
3753
+ text
3754
+ });
3755
+ }
3756
+ return transcripts;
3757
+ }
3758
+ async transcribeOne(attachment) {
3759
+ const buffer = await import_promises3.default.readFile(attachment.localPath);
3760
+ const formData = new FormData();
3761
+ formData.append("model", this.model);
3762
+ formData.append("response_format", "json");
3763
+ formData.append(
3764
+ "file",
3765
+ new Blob([buffer], { type: attachment.mimeType ?? "application/octet-stream" }),
3766
+ import_node_path8.default.basename(attachment.localPath)
3767
+ );
3768
+ const controller = new AbortController();
3769
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
3770
+ timer.unref?.();
3771
+ let response;
3772
+ try {
3773
+ response = await fetch("https://api.openai.com/v1/audio/transcriptions", {
3774
+ method: "POST",
3775
+ headers: {
3776
+ Authorization: `Bearer ${this.apiKey}`
3777
+ },
3778
+ body: formData,
3779
+ signal: controller.signal
3780
+ });
3781
+ } finally {
3782
+ clearTimeout(timer);
3783
+ }
3784
+ const payload = await response.json().catch(() => ({}));
3785
+ if (!response.ok) {
3786
+ const message = typeof payload?.error?.message === "string" ? payload.error.message : `HTTP ${response.status} ${response.statusText}`;
3787
+ throw new Error(`Audio transcription failed for ${attachment.name}: ${message}`);
3788
+ }
3789
+ const text = typeof payload.text === "string" ? payload.text.trim() : "";
3790
+ if (!text) {
3791
+ return "";
3792
+ }
3793
+ if (text.length > this.maxChars) {
3794
+ return `${text.slice(0, this.maxChars)}...`;
3795
+ }
3796
+ return text;
3797
+ }
3798
+ };
3631
3799
 
3632
3800
  // src/compat/cli-compat-recorder.ts
3633
3801
  var import_node_fs6 = __toESM(require("fs"));
3634
- var import_node_path8 = __toESM(require("path"));
3802
+ var import_node_path9 = __toESM(require("path"));
3635
3803
  var CliCompatRecorder = class {
3636
3804
  filePath;
3637
3805
  chain = Promise.resolve();
3638
3806
  constructor(filePath) {
3639
- this.filePath = import_node_path8.default.resolve(filePath);
3640
- import_node_fs6.default.mkdirSync(import_node_path8.default.dirname(this.filePath), { recursive: true });
3807
+ this.filePath = import_node_path9.default.resolve(filePath);
3808
+ import_node_fs6.default.mkdirSync(import_node_path9.default.dirname(this.filePath), { recursive: true });
3641
3809
  }
3642
3810
  append(entry) {
3643
3811
  const payload = `${JSON.stringify(entry)}
@@ -4122,8 +4290,8 @@ function createIdleWorkflowSnapshot() {
4122
4290
  }
4123
4291
 
4124
4292
  // src/workflow/autodev.ts
4125
- var import_promises3 = __toESM(require("fs/promises"));
4126
- var import_node_path9 = __toESM(require("path"));
4293
+ var import_promises4 = __toESM(require("fs/promises"));
4294
+ var import_node_path10 = __toESM(require("path"));
4127
4295
  function parseAutoDevCommand(text) {
4128
4296
  const normalized = text.trim();
4129
4297
  if (!/^\/autodev(?:\s|$)/i.test(normalized)) {
@@ -4143,8 +4311,8 @@ function parseAutoDevCommand(text) {
4143
4311
  };
4144
4312
  }
4145
4313
  async function loadAutoDevContext(workdir) {
4146
- const requirementsPath = import_node_path9.default.join(workdir, "REQUIREMENTS.md");
4147
- const taskListPath = import_node_path9.default.join(workdir, "TASK_LIST.md");
4314
+ const requirementsPath = import_node_path10.default.join(workdir, "REQUIREMENTS.md");
4315
+ const taskListPath = import_node_path10.default.join(workdir, "TASK_LIST.md");
4148
4316
  const requirementsContent = await readOptionalFile(requirementsPath);
4149
4317
  const taskListContent = await readOptionalFile(taskListPath);
4150
4318
  return {
@@ -4234,7 +4402,7 @@ function statusToSymbol(status) {
4234
4402
  return "\u{1F6AB}";
4235
4403
  }
4236
4404
  async function updateAutoDevTaskStatus(taskListPath, task, nextStatus) {
4237
- const content = await import_promises3.default.readFile(taskListPath, "utf8");
4405
+ const content = await import_promises4.default.readFile(taskListPath, "utf8");
4238
4406
  const lines = splitLines(content);
4239
4407
  if (task.lineIndex < 0 || task.lineIndex >= lines.length) {
4240
4408
  throw new Error(`task ${task.id} line index out of range`);
@@ -4244,7 +4412,7 @@ async function updateAutoDevTaskStatus(taskListPath, task, nextStatus) {
4244
4412
  throw new Error(`failed to update task status for ${task.id}`);
4245
4413
  }
4246
4414
  lines[task.lineIndex] = updatedLine;
4247
- await import_promises3.default.writeFile(taskListPath, lines.join("\n"), "utf8");
4415
+ await import_promises4.default.writeFile(taskListPath, lines.join("\n"), "utf8");
4248
4416
  return {
4249
4417
  ...task,
4250
4418
  status: nextStatus
@@ -4252,7 +4420,7 @@ async function updateAutoDevTaskStatus(taskListPath, task, nextStatus) {
4252
4420
  }
4253
4421
  async function readOptionalFile(filePath) {
4254
4422
  try {
4255
- return await import_promises3.default.readFile(filePath, "utf8");
4423
+ return await import_promises4.default.readFile(filePath, "utf8");
4256
4424
  } catch (error) {
4257
4425
  if (error.code === "ENOENT") {
4258
4426
  return null;
@@ -4483,6 +4651,7 @@ var Orchestrator = class {
4483
4651
  logger;
4484
4652
  sessionLocks = /* @__PURE__ */ new Map();
4485
4653
  runningExecutions = /* @__PURE__ */ new Map();
4654
+ pendingStopRequests = /* @__PURE__ */ new Set();
4486
4655
  lockTtlMs;
4487
4656
  lockPruneIntervalMs;
4488
4657
  progressUpdatesEnabled;
@@ -4499,6 +4668,7 @@ var Orchestrator = class {
4499
4668
  rateLimiter;
4500
4669
  cliCompat;
4501
4670
  cliCompatRecorder;
4671
+ audioTranscriber;
4502
4672
  workflowRunner;
4503
4673
  workflowSnapshots = /* @__PURE__ */ new Map();
4504
4674
  autoDevSnapshots = /* @__PURE__ */ new Map();
@@ -4519,9 +4689,20 @@ var Orchestrator = class {
4519
4689
  disableReplyChunkSplit: false,
4520
4690
  progressThrottleMs: 300,
4521
4691
  fetchMedia: false,
4692
+ transcribeAudio: false,
4693
+ audioTranscribeModel: "gpt-4o-mini-transcribe",
4694
+ audioTranscribeTimeoutMs: 12e4,
4695
+ audioTranscribeMaxChars: 6e3,
4522
4696
  recordPath: null
4523
4697
  };
4524
4698
  this.cliCompatRecorder = this.cliCompat.recordPath ? new CliCompatRecorder(this.cliCompat.recordPath) : null;
4699
+ this.audioTranscriber = options?.audioTranscriber ?? new AudioTranscriber({
4700
+ enabled: this.cliCompat.transcribeAudio,
4701
+ apiKey: process.env.OPENAI_API_KEY?.trim() || null,
4702
+ model: this.cliCompat.audioTranscribeModel,
4703
+ timeoutMs: this.cliCompat.audioTranscribeTimeoutMs,
4704
+ maxChars: this.cliCompat.audioTranscribeMaxChars
4705
+ });
4525
4706
  const defaultProgressInterval = options?.progressMinIntervalMs ?? 2500;
4526
4707
  this.progressMinIntervalMs = this.cliCompat.enabled ? this.cliCompat.progressThrottleMs : defaultProgressInterval;
4527
4708
  this.typingTimeoutMs = options?.typingTimeoutMs ?? 1e4;
@@ -4556,7 +4737,7 @@ var Orchestrator = class {
4556
4737
  this.sessionRuntime = new CodexSessionRuntime(this.executor);
4557
4738
  }
4558
4739
  async handleMessage(message) {
4559
- const attachmentPaths = collectImagePaths(message);
4740
+ const attachmentPaths = collectLocalAttachmentPaths(message);
4560
4741
  try {
4561
4742
  const receivedAt = Date.now();
4562
4743
  const requestId = message.requestId || message.eventId;
@@ -4691,7 +4872,8 @@ var Orchestrator = class {
4691
4872
  }
4692
4873
  this.stateStore.activateSession(sessionKey, this.sessionActiveWindowMs);
4693
4874
  const previousCodexSessionId = this.stateStore.getCodexSessionId(sessionKey);
4694
- const executionPrompt = this.buildExecutionPrompt(route.prompt, message);
4875
+ const audioTranscripts = await this.transcribeAudioAttachments(message, requestId, sessionKey);
4876
+ const executionPrompt = this.buildExecutionPrompt(route.prompt, message, audioTranscripts);
4695
4877
  const imagePaths = collectImagePaths(message);
4696
4878
  let lastProgressAt = 0;
4697
4879
  let lastProgressText = "";
@@ -4701,7 +4883,7 @@ var Orchestrator = class {
4701
4883
  let executionDurationMs = 0;
4702
4884
  let sendDurationMs = 0;
4703
4885
  const requestStartedAt = Date.now();
4704
- let cancelRequested = false;
4886
+ let cancelRequested = this.consumePendingStopRequest(sessionKey);
4705
4887
  this.runningExecutions.set(sessionKey, {
4706
4888
  requestId,
4707
4889
  startedAt: requestStartedAt,
@@ -5208,6 +5390,7 @@ var Orchestrator = class {
5208
5390
  this.sessionRuntime.clearSession(sessionKey);
5209
5391
  const running = this.runningExecutions.get(sessionKey);
5210
5392
  if (running) {
5393
+ this.pendingStopRequests.delete(sessionKey);
5211
5394
  this.sessionRuntime.cancelRunningExecution(sessionKey);
5212
5395
  running.cancel();
5213
5396
  await this.channel.sendNotice(
@@ -5222,11 +5405,32 @@ var Orchestrator = class {
5222
5405
  });
5223
5406
  return;
5224
5407
  }
5408
+ const lockEntry = this.sessionLocks.get(sessionKey);
5409
+ if (lockEntry?.mutex.isLocked()) {
5410
+ this.pendingStopRequests.add(sessionKey);
5411
+ await this.channel.sendNotice(
5412
+ message.conversationId,
5413
+ "[CodeHarbor] \u5DF2\u8BF7\u6C42\u505C\u6B62\u5F53\u524D\u4EFB\u52A1\uFF0C\u5E76\u5DF2\u6E05\u7406\u4F1A\u8BDD\u4E0A\u4E0B\u6587\u3002"
5414
+ );
5415
+ this.logger.info("Stop command queued for pending execution", {
5416
+ requestId,
5417
+ sessionKey
5418
+ });
5419
+ return;
5420
+ }
5421
+ this.pendingStopRequests.delete(sessionKey);
5225
5422
  await this.channel.sendNotice(
5226
5423
  message.conversationId,
5227
5424
  "[CodeHarbor] \u4F1A\u8BDD\u5DF2\u505C\u6B62\u3002\u540E\u7EED\u5728\u7FA4\u804A\u4E2D\u8BF7\u63D0\u53CA/\u56DE\u590D\u6211\uFF0C\u6216\u5728\u79C1\u804A\u76F4\u63A5\u53D1\u9001\u6D88\u606F\u3002"
5228
5425
  );
5229
5426
  }
5427
+ consumePendingStopRequest(sessionKey) {
5428
+ if (!this.pendingStopRequests.has(sessionKey)) {
5429
+ return false;
5430
+ }
5431
+ this.pendingStopRequests.delete(sessionKey);
5432
+ return true;
5433
+ }
5230
5434
  startTypingHeartbeat(conversationId) {
5231
5435
  let stopped = false;
5232
5436
  const refreshIntervalMs = Math.max(1e3, Math.floor(this.typingTimeoutMs / 2));
@@ -5328,8 +5532,41 @@ var Orchestrator = class {
5328
5532
  }
5329
5533
  return this.configService.resolveRoomConfig(conversationId, fallbackPolicy);
5330
5534
  }
5331
- buildExecutionPrompt(prompt, message) {
5332
- if (message.attachments.length === 0) {
5535
+ async transcribeAudioAttachments(message, requestId, sessionKey) {
5536
+ if (!this.audioTranscriber.isEnabled()) {
5537
+ return [];
5538
+ }
5539
+ const audioAttachments = message.attachments.filter((attachment) => attachment.kind === "audio" && Boolean(attachment.localPath)).map((attachment) => ({
5540
+ name: attachment.name,
5541
+ mimeType: attachment.mimeType,
5542
+ localPath: attachment.localPath
5543
+ }));
5544
+ if (audioAttachments.length === 0) {
5545
+ return [];
5546
+ }
5547
+ try {
5548
+ const transcripts = await this.audioTranscriber.transcribeMany(audioAttachments);
5549
+ if (transcripts.length > 0) {
5550
+ this.logger.info("Audio transcription completed", {
5551
+ requestId,
5552
+ sessionKey,
5553
+ attachmentCount: audioAttachments.length,
5554
+ transcriptCount: transcripts.length
5555
+ });
5556
+ }
5557
+ return transcripts;
5558
+ } catch (error) {
5559
+ this.logger.warn("Audio transcription failed, continuing without transcripts", {
5560
+ requestId,
5561
+ sessionKey,
5562
+ attachmentCount: audioAttachments.length,
5563
+ error: formatError3(error)
5564
+ });
5565
+ return [];
5566
+ }
5567
+ }
5568
+ buildExecutionPrompt(prompt, message, audioTranscripts) {
5569
+ if (message.attachments.length === 0 && audioTranscripts.length === 0) {
5333
5570
  return prompt;
5334
5571
  }
5335
5572
  const attachmentSummary = message.attachments.map((attachment) => {
@@ -5340,11 +5577,19 @@ var Orchestrator = class {
5340
5577
  return `- kind=${attachment.kind} name=${attachment.name} mime=${mime} size=${size} source=${source} local=${local}`;
5341
5578
  }).join("\n");
5342
5579
  const promptBody = prompt.trim() ? prompt : "(no text body)";
5343
- return `${promptBody}
5344
-
5345
- [attachments]
5580
+ const sections = [promptBody];
5581
+ if (attachmentSummary) {
5582
+ sections.push(`[attachments]
5346
5583
  ${attachmentSummary}
5347
- [/attachments]`;
5584
+ [/attachments]`);
5585
+ }
5586
+ if (audioTranscripts.length > 0) {
5587
+ const transcriptSummary = audioTranscripts.map((transcript) => `- name=${transcript.name} text=${transcript.text.replace(/\s+/g, " ").trim()}`).join("\n");
5588
+ sections.push(`[audio_transcripts]
5589
+ ${transcriptSummary}
5590
+ [/audio_transcripts]`);
5591
+ }
5592
+ return sections.join("\n\n");
5348
5593
  }
5349
5594
  async recordCliCompatPrompt(entry) {
5350
5595
  if (!this.cliCompatRecorder) {
@@ -5489,11 +5734,21 @@ function collectImagePaths(message) {
5489
5734
  }
5490
5735
  return [...seen];
5491
5736
  }
5492
- async function cleanupAttachmentFiles(imagePaths) {
5737
+ function collectLocalAttachmentPaths(message) {
5738
+ const seen = /* @__PURE__ */ new Set();
5739
+ for (const attachment of message.attachments) {
5740
+ if (!attachment.localPath) {
5741
+ continue;
5742
+ }
5743
+ seen.add(attachment.localPath);
5744
+ }
5745
+ return [...seen];
5746
+ }
5747
+ async function cleanupAttachmentFiles(attachmentPaths) {
5493
5748
  await Promise.all(
5494
- imagePaths.map(async (imagePath) => {
5749
+ attachmentPaths.map(async (attachmentPath) => {
5495
5750
  try {
5496
- await import_promises4.default.unlink(imagePath);
5751
+ await import_promises5.default.unlink(attachmentPath);
5497
5752
  } catch {
5498
5753
  }
5499
5754
  })
@@ -5610,7 +5865,7 @@ ${result.review}
5610
5865
 
5611
5866
  // src/store/state-store.ts
5612
5867
  var import_node_fs7 = __toESM(require("fs"));
5613
- var import_node_path10 = __toESM(require("path"));
5868
+ var import_node_path11 = __toESM(require("path"));
5614
5869
  var ONE_DAY_MS = 24 * 60 * 60 * 1e3;
5615
5870
  var PRUNE_INTERVAL_MS = 5 * 60 * 1e3;
5616
5871
  var SQLITE_MODULE_ID = `node:${"sqlite"}`;
@@ -5636,7 +5891,7 @@ var StateStore = class {
5636
5891
  this.maxProcessedEventsPerSession = maxProcessedEventsPerSession;
5637
5892
  this.maxSessionAgeMs = maxSessionAgeDays * ONE_DAY_MS;
5638
5893
  this.maxSessions = maxSessions;
5639
- import_node_fs7.default.mkdirSync(import_node_path10.default.dirname(this.dbPath), { recursive: true });
5894
+ import_node_fs7.default.mkdirSync(import_node_path11.default.dirname(this.dbPath), { recursive: true });
5640
5895
  this.db = new DatabaseSync(this.dbPath);
5641
5896
  this.initializeSchema();
5642
5897
  this.importLegacyStateIfNeeded();
@@ -6159,7 +6414,7 @@ function isNonLoopbackHost(host) {
6159
6414
 
6160
6415
  // src/config.ts
6161
6416
  var import_node_fs8 = __toESM(require("fs"));
6162
- var import_node_path11 = __toESM(require("path"));
6417
+ var import_node_path12 = __toESM(require("path"));
6163
6418
  var import_dotenv2 = __toESM(require("dotenv"));
6164
6419
  var import_zod = require("zod");
6165
6420
  var configSchema = import_zod.z.object({
@@ -6206,6 +6461,10 @@ var configSchema = import_zod.z.object({
6206
6461
  CLI_COMPAT_DISABLE_REPLY_CHUNK_SPLIT: import_zod.z.string().default("false").transform((v) => v.toLowerCase() === "true"),
6207
6462
  CLI_COMPAT_PROGRESS_THROTTLE_MS: import_zod.z.string().default("300").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().nonnegative()),
6208
6463
  CLI_COMPAT_FETCH_MEDIA: import_zod.z.string().default("true").transform((v) => v.toLowerCase() === "true"),
6464
+ CLI_COMPAT_TRANSCRIBE_AUDIO: import_zod.z.string().default("false").transform((v) => v.toLowerCase() === "true"),
6465
+ CLI_COMPAT_AUDIO_TRANSCRIBE_MODEL: import_zod.z.string().default("gpt-4o-mini-transcribe"),
6466
+ CLI_COMPAT_AUDIO_TRANSCRIBE_TIMEOUT_MS: import_zod.z.string().default("120000").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().positive()),
6467
+ CLI_COMPAT_AUDIO_TRANSCRIBE_MAX_CHARS: import_zod.z.string().default("6000").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().positive()),
6209
6468
  CLI_COMPAT_RECORD_PATH: import_zod.z.string().default(""),
6210
6469
  DOCTOR_HTTP_TIMEOUT_MS: import_zod.z.string().default("10000").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().positive()),
6211
6470
  ADMIN_BIND_HOST: import_zod.z.string().default("127.0.0.1"),
@@ -6222,7 +6481,7 @@ var configSchema = import_zod.z.object({
6222
6481
  matrixCommandPrefix: v.MATRIX_COMMAND_PREFIX,
6223
6482
  codexBin: v.CODEX_BIN,
6224
6483
  codexModel: v.CODEX_MODEL?.trim() || null,
6225
- codexWorkdir: import_node_path11.default.resolve(v.CODEX_WORKDIR),
6484
+ codexWorkdir: import_node_path12.default.resolve(v.CODEX_WORKDIR),
6226
6485
  codexDangerousBypass: v.CODEX_DANGEROUS_BYPASS,
6227
6486
  codexExecTimeoutMs: v.CODEX_EXEC_TIMEOUT_MS,
6228
6487
  codexSandboxMode: v.CODEX_SANDBOX_MODE?.trim() || null,
@@ -6233,8 +6492,8 @@ var configSchema = import_zod.z.object({
6233
6492
  enabled: v.AGENT_WORKFLOW_ENABLED,
6234
6493
  autoRepairMaxRounds: v.AGENT_WORKFLOW_AUTO_REPAIR_MAX_ROUNDS
6235
6494
  },
6236
- stateDbPath: import_node_path11.default.resolve(v.STATE_DB_PATH),
6237
- legacyStateJsonPath: v.STATE_PATH.trim() ? import_node_path11.default.resolve(v.STATE_PATH) : null,
6495
+ stateDbPath: import_node_path12.default.resolve(v.STATE_DB_PATH),
6496
+ legacyStateJsonPath: v.STATE_PATH.trim() ? import_node_path12.default.resolve(v.STATE_PATH) : null,
6238
6497
  maxProcessedEventsPerSession: v.MAX_PROCESSED_EVENTS_PER_SESSION,
6239
6498
  maxSessionAgeDays: v.MAX_SESSION_AGE_DAYS,
6240
6499
  maxSessions: v.MAX_SESSIONS,
@@ -6266,7 +6525,11 @@ var configSchema = import_zod.z.object({
6266
6525
  disableReplyChunkSplit: v.CLI_COMPAT_DISABLE_REPLY_CHUNK_SPLIT,
6267
6526
  progressThrottleMs: v.CLI_COMPAT_PROGRESS_THROTTLE_MS,
6268
6527
  fetchMedia: v.CLI_COMPAT_FETCH_MEDIA,
6269
- recordPath: v.CLI_COMPAT_RECORD_PATH.trim() ? import_node_path11.default.resolve(v.CLI_COMPAT_RECORD_PATH) : null
6528
+ transcribeAudio: v.CLI_COMPAT_TRANSCRIBE_AUDIO,
6529
+ audioTranscribeModel: v.CLI_COMPAT_AUDIO_TRANSCRIBE_MODEL.trim() || "gpt-4o-mini-transcribe",
6530
+ audioTranscribeTimeoutMs: v.CLI_COMPAT_AUDIO_TRANSCRIBE_TIMEOUT_MS,
6531
+ audioTranscribeMaxChars: v.CLI_COMPAT_AUDIO_TRANSCRIBE_MAX_CHARS,
6532
+ recordPath: v.CLI_COMPAT_RECORD_PATH.trim() ? import_node_path12.default.resolve(v.CLI_COMPAT_RECORD_PATH) : null
6270
6533
  },
6271
6534
  doctorHttpTimeoutMs: v.DOCTOR_HTTP_TIMEOUT_MS,
6272
6535
  adminBindHost: v.ADMIN_BIND_HOST.trim() || "127.0.0.1",
@@ -6277,7 +6540,7 @@ var configSchema = import_zod.z.object({
6277
6540
  adminAllowedOrigins: parseCsvList(v.ADMIN_ALLOWED_ORIGINS),
6278
6541
  logLevel: v.LOG_LEVEL
6279
6542
  }));
6280
- function loadEnvFromFile(filePath = import_node_path11.default.resolve(process.cwd(), ".env"), env = process.env) {
6543
+ function loadEnvFromFile(filePath = import_node_path12.default.resolve(process.cwd(), ".env"), env = process.env) {
6281
6544
  import_dotenv2.default.config({
6282
6545
  path: filePath,
6283
6546
  processEnv: env,
@@ -6290,9 +6553,9 @@ function loadConfig(env = process.env) {
6290
6553
  const message = parsed.error.issues.map((issue) => `${issue.path.join(".") || "config"}: ${issue.message}`).join("; ");
6291
6554
  throw new Error(`Invalid configuration: ${message}`);
6292
6555
  }
6293
- import_node_fs8.default.mkdirSync(import_node_path11.default.dirname(parsed.data.stateDbPath), { recursive: true });
6556
+ import_node_fs8.default.mkdirSync(import_node_path12.default.dirname(parsed.data.stateDbPath), { recursive: true });
6294
6557
  if (parsed.data.legacyStateJsonPath) {
6295
- import_node_fs8.default.mkdirSync(import_node_path11.default.dirname(parsed.data.legacyStateJsonPath), { recursive: true });
6558
+ import_node_fs8.default.mkdirSync(import_node_path12.default.dirname(parsed.data.legacyStateJsonPath), { recursive: true });
6296
6559
  }
6297
6560
  return parsed.data;
6298
6561
  }
@@ -6460,7 +6723,7 @@ function parseAdminTokens(raw) {
6460
6723
 
6461
6724
  // src/config-snapshot.ts
6462
6725
  var import_node_fs9 = __toESM(require("fs"));
6463
- var import_node_path12 = __toESM(require("path"));
6726
+ var import_node_path13 = __toESM(require("path"));
6464
6727
  var import_zod2 = require("zod");
6465
6728
  var CONFIG_SNAPSHOT_SCHEMA_VERSION = 1;
6466
6729
  var CONFIG_SNAPSHOT_ENV_KEYS = [
@@ -6507,6 +6770,10 @@ var CONFIG_SNAPSHOT_ENV_KEYS = [
6507
6770
  "CLI_COMPAT_DISABLE_REPLY_CHUNK_SPLIT",
6508
6771
  "CLI_COMPAT_PROGRESS_THROTTLE_MS",
6509
6772
  "CLI_COMPAT_FETCH_MEDIA",
6773
+ "CLI_COMPAT_TRANSCRIBE_AUDIO",
6774
+ "CLI_COMPAT_AUDIO_TRANSCRIBE_MODEL",
6775
+ "CLI_COMPAT_AUDIO_TRANSCRIBE_TIMEOUT_MS",
6776
+ "CLI_COMPAT_AUDIO_TRANSCRIBE_MAX_CHARS",
6510
6777
  "CLI_COMPAT_RECORD_PATH",
6511
6778
  "DOCTOR_HTTP_TIMEOUT_MS",
6512
6779
  "ADMIN_BIND_HOST",
@@ -6573,6 +6840,14 @@ var envSnapshotSchema = import_zod2.z.object({
6573
6840
  CLI_COMPAT_DISABLE_REPLY_CHUNK_SPLIT: booleanStringSchema("CLI_COMPAT_DISABLE_REPLY_CHUNK_SPLIT"),
6574
6841
  CLI_COMPAT_PROGRESS_THROTTLE_MS: integerStringSchema("CLI_COMPAT_PROGRESS_THROTTLE_MS", 0),
6575
6842
  CLI_COMPAT_FETCH_MEDIA: booleanStringSchema("CLI_COMPAT_FETCH_MEDIA"),
6843
+ CLI_COMPAT_TRANSCRIBE_AUDIO: booleanStringSchema("CLI_COMPAT_TRANSCRIBE_AUDIO").default("false"),
6844
+ CLI_COMPAT_AUDIO_TRANSCRIBE_MODEL: import_zod2.z.string().default("gpt-4o-mini-transcribe"),
6845
+ CLI_COMPAT_AUDIO_TRANSCRIBE_TIMEOUT_MS: integerStringSchema("CLI_COMPAT_AUDIO_TRANSCRIBE_TIMEOUT_MS", 1).default(
6846
+ "120000"
6847
+ ),
6848
+ CLI_COMPAT_AUDIO_TRANSCRIBE_MAX_CHARS: integerStringSchema("CLI_COMPAT_AUDIO_TRANSCRIBE_MAX_CHARS", 1).default(
6849
+ "6000"
6850
+ ),
6576
6851
  CLI_COMPAT_RECORD_PATH: import_zod2.z.string(),
6577
6852
  DOCTOR_HTTP_TIMEOUT_MS: integerStringSchema("DOCTOR_HTTP_TIMEOUT_MS", 1),
6578
6853
  ADMIN_BIND_HOST: import_zod2.z.string(),
@@ -6651,8 +6926,8 @@ async function runConfigExportCommand(options = {}) {
6651
6926
  const snapshot = buildConfigSnapshot(config, stateStore.listRoomSettings(), options.now ?? /* @__PURE__ */ new Date());
6652
6927
  const serialized = serializeConfigSnapshot(snapshot);
6653
6928
  if (options.outputPath) {
6654
- const targetPath = import_node_path12.default.resolve(cwd, options.outputPath);
6655
- import_node_fs9.default.mkdirSync(import_node_path12.default.dirname(targetPath), { recursive: true });
6929
+ const targetPath = import_node_path13.default.resolve(cwd, options.outputPath);
6930
+ import_node_fs9.default.mkdirSync(import_node_path13.default.dirname(targetPath), { recursive: true });
6656
6931
  import_node_fs9.default.writeFileSync(targetPath, serialized, "utf8");
6657
6932
  output.write(`Exported config snapshot to ${targetPath}
6658
6933
  `);
@@ -6667,7 +6942,7 @@ async function runConfigImportCommand(options) {
6667
6942
  const cwd = options.cwd ?? process.cwd();
6668
6943
  const output = options.output ?? process.stdout;
6669
6944
  const actor = options.actor?.trim() || "cli:config-import";
6670
- const sourcePath = import_node_path12.default.resolve(cwd, options.filePath);
6945
+ const sourcePath = import_node_path13.default.resolve(cwd, options.filePath);
6671
6946
  if (!import_node_fs9.default.existsSync(sourcePath)) {
6672
6947
  throw new Error(`Config snapshot file not found: ${sourcePath}`);
6673
6948
  }
@@ -6698,7 +6973,7 @@ async function runConfigImportCommand(options) {
6698
6973
  synchronizeRoomSettings(stateStore, normalizedRooms);
6699
6974
  stateStore.appendConfigRevision(
6700
6975
  actor,
6701
- `import config snapshot from ${import_node_path12.default.basename(sourcePath)}`,
6976
+ `import config snapshot from ${import_node_path13.default.basename(sourcePath)}`,
6702
6977
  JSON.stringify({
6703
6978
  type: "config_snapshot_import",
6704
6979
  sourcePath,
@@ -6712,7 +6987,7 @@ async function runConfigImportCommand(options) {
6712
6987
  output.write(
6713
6988
  [
6714
6989
  `Imported config snapshot from ${sourcePath}`,
6715
- `- updated .env in ${import_node_path12.default.resolve(cwd, ".env")}`,
6990
+ `- updated .env in ${import_node_path13.default.resolve(cwd, ".env")}`,
6716
6991
  `- synchronized room settings: ${normalizedRooms.length}`,
6717
6992
  "- restart required: yes (global env settings are restart-scoped)"
6718
6993
  ].join("\n") + "\n"
@@ -6763,6 +7038,10 @@ function buildSnapshotEnv(config) {
6763
7038
  CLI_COMPAT_DISABLE_REPLY_CHUNK_SPLIT: String(config.cliCompat.disableReplyChunkSplit),
6764
7039
  CLI_COMPAT_PROGRESS_THROTTLE_MS: String(config.cliCompat.progressThrottleMs),
6765
7040
  CLI_COMPAT_FETCH_MEDIA: String(config.cliCompat.fetchMedia),
7041
+ CLI_COMPAT_TRANSCRIBE_AUDIO: String(config.cliCompat.transcribeAudio),
7042
+ CLI_COMPAT_AUDIO_TRANSCRIBE_MODEL: config.cliCompat.audioTranscribeModel,
7043
+ CLI_COMPAT_AUDIO_TRANSCRIBE_TIMEOUT_MS: String(config.cliCompat.audioTranscribeTimeoutMs),
7044
+ CLI_COMPAT_AUDIO_TRANSCRIBE_MAX_CHARS: String(config.cliCompat.audioTranscribeMaxChars),
6766
7045
  CLI_COMPAT_RECORD_PATH: config.cliCompat.recordPath ?? "",
6767
7046
  DOCTOR_HTTP_TIMEOUT_MS: String(config.doctorHttpTimeoutMs),
6768
7047
  ADMIN_BIND_HOST: config.adminBindHost,
@@ -6788,10 +7067,10 @@ function parseJsonFile(filePath) {
6788
7067
  function normalizeSnapshotEnv(env, cwd) {
6789
7068
  return {
6790
7069
  ...env,
6791
- CODEX_WORKDIR: import_node_path12.default.resolve(cwd, env.CODEX_WORKDIR),
6792
- STATE_DB_PATH: import_node_path12.default.resolve(cwd, env.STATE_DB_PATH),
6793
- STATE_PATH: env.STATE_PATH.trim() ? import_node_path12.default.resolve(cwd, env.STATE_PATH) : "",
6794
- CLI_COMPAT_RECORD_PATH: env.CLI_COMPAT_RECORD_PATH.trim() ? import_node_path12.default.resolve(cwd, env.CLI_COMPAT_RECORD_PATH) : ""
7070
+ CODEX_WORKDIR: import_node_path13.default.resolve(cwd, env.CODEX_WORKDIR),
7071
+ STATE_DB_PATH: import_node_path13.default.resolve(cwd, env.STATE_DB_PATH),
7072
+ STATE_PATH: env.STATE_PATH.trim() ? import_node_path13.default.resolve(cwd, env.STATE_PATH) : "",
7073
+ CLI_COMPAT_RECORD_PATH: env.CLI_COMPAT_RECORD_PATH.trim() ? import_node_path13.default.resolve(cwd, env.CLI_COMPAT_RECORD_PATH) : ""
6795
7074
  };
6796
7075
  }
6797
7076
  function normalizeSnapshotRooms(rooms, cwd) {
@@ -6806,7 +7085,7 @@ function normalizeSnapshotRooms(rooms, cwd) {
6806
7085
  throw new Error(`Duplicate roomId in snapshot: ${roomId}`);
6807
7086
  }
6808
7087
  seen.add(roomId);
6809
- const workdir = import_node_path12.default.resolve(cwd, room.workdir);
7088
+ const workdir = import_node_path13.default.resolve(cwd, room.workdir);
6810
7089
  ensureDirectory2(workdir, `room workdir (${roomId})`);
6811
7090
  normalized.push({
6812
7091
  roomId,
@@ -6833,8 +7112,8 @@ function synchronizeRoomSettings(stateStore, rooms) {
6833
7112
  }
6834
7113
  }
6835
7114
  function persistEnvSnapshot(cwd, env) {
6836
- const envPath = import_node_path12.default.resolve(cwd, ".env");
6837
- const examplePath = import_node_path12.default.resolve(cwd, ".env.example");
7115
+ const envPath = import_node_path13.default.resolve(cwd, ".env");
7116
+ const examplePath = import_node_path13.default.resolve(cwd, ".env.example");
6838
7117
  const template = import_node_fs9.default.existsSync(envPath) ? import_node_fs9.default.readFileSync(envPath, "utf8") : import_node_fs9.default.existsSync(examplePath) ? import_node_fs9.default.readFileSync(examplePath, "utf8") : "";
6839
7118
  const overrides = {};
6840
7119
  for (const key of CONFIG_SNAPSHOT_ENV_KEYS) {
@@ -6919,7 +7198,7 @@ function jsonArrayStringSchema(key, allowEmpty) {
6919
7198
  // src/preflight.ts
6920
7199
  var import_node_child_process6 = require("child_process");
6921
7200
  var import_node_fs10 = __toESM(require("fs"));
6922
- var import_node_path13 = __toESM(require("path"));
7201
+ var import_node_path14 = __toESM(require("path"));
6923
7202
  var import_node_util4 = require("util");
6924
7203
  var execFileAsync4 = (0, import_node_util4.promisify)(import_node_child_process6.execFile);
6925
7204
  var REQUIRED_ENV_KEYS = ["MATRIX_HOMESERVER", "MATRIX_USER_ID", "MATRIX_ACCESS_TOKEN"];
@@ -6930,7 +7209,7 @@ async function runStartupPreflight(options = {}) {
6930
7209
  const fileExists = options.fileExists ?? import_node_fs10.default.existsSync;
6931
7210
  const isDirectory = options.isDirectory ?? defaultIsDirectory;
6932
7211
  const issues = [];
6933
- const envPath = import_node_path13.default.resolve(cwd, ".env");
7212
+ const envPath = import_node_path14.default.resolve(cwd, ".env");
6934
7213
  let resolvedCodexBin = null;
6935
7214
  let usedCodexFallback = false;
6936
7215
  if (!fileExists(envPath)) {
@@ -7007,7 +7286,7 @@ async function runStartupPreflight(options = {}) {
7007
7286
  }
7008
7287
  }
7009
7288
  const configuredWorkdir = readEnv(env, "CODEX_WORKDIR");
7010
- const workdir = import_node_path13.default.resolve(cwd, configuredWorkdir || cwd);
7289
+ const workdir = import_node_path14.default.resolve(cwd, configuredWorkdir || cwd);
7011
7290
  if (!fileExists(workdir) || !isDirectory(workdir)) {
7012
7291
  issues.push({
7013
7292
  level: "error",
@@ -7276,7 +7555,7 @@ function ensureRuntimeHomeOrExit() {
7276
7555
  `);
7277
7556
  process.exit(1);
7278
7557
  }
7279
- loadEnvFromFile(import_node_path14.default.resolve(home, ".env"));
7558
+ loadEnvFromFile(import_node_path15.default.resolve(home, ".env"));
7280
7559
  runtimeHome = home;
7281
7560
  return runtimeHome;
7282
7561
  }
@@ -7291,7 +7570,7 @@ function parsePortOption(raw, fallback) {
7291
7570
  }
7292
7571
  function resolveCliVersion() {
7293
7572
  try {
7294
- const packagePath = import_node_path14.default.resolve(__dirname, "..", "package.json");
7573
+ const packagePath = import_node_path15.default.resolve(__dirname, "..", "package.json");
7295
7574
  const content = import_node_fs11.default.readFileSync(packagePath, "utf8");
7296
7575
  const parsed = JSON.parse(content);
7297
7576
  return typeof parsed.version === "string" && parsed.version.trim() ? parsed.version : "0.0.0";
@@ -7302,9 +7581,9 @@ function resolveCliVersion() {
7302
7581
  function resolveCliScriptPath() {
7303
7582
  const argvPath = process.argv[1];
7304
7583
  if (argvPath && argvPath.trim()) {
7305
- return import_node_path14.default.resolve(argvPath);
7584
+ return import_node_path15.default.resolve(argvPath);
7306
7585
  }
7307
- return import_node_path14.default.resolve(__dirname, "cli.js");
7586
+ return import_node_path15.default.resolve(__dirname, "cli.js");
7308
7587
  }
7309
7588
  function maybeReexecServiceCommandWithSudo() {
7310
7589
  if (typeof process.getuid !== "function" || process.getuid() === 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codeharbor",
3
- "version": "0.1.18",
3
+ "version": "0.1.20",
4
4
  "description": "Instant-messaging bridge for Codex CLI sessions",
5
5
  "license": "MIT",
6
6
  "main": "dist/cli.js",
@@ -17,6 +17,7 @@
17
17
  },
18
18
  "files": [
19
19
  "dist",
20
+ "scripts/postinstall-restart.cjs",
20
21
  ".env.example",
21
22
  "README.md",
22
23
  "LICENSE"
@@ -55,6 +56,7 @@
55
56
  "e2e:install": "playwright install chromium",
56
57
  "test:watch": "vitest test",
57
58
  "test:legacy": "bash ./scripts/run-legacy-tests.sh",
59
+ "postinstall": "node ./scripts/postinstall-restart.cjs",
58
60
  "prepare": "npm run build",
59
61
  "prepublishOnly": "npm run changelog:check && npm run typecheck && npm run lint && npm run test:coverage && npm run build",
60
62
  "changelog:check": "bash ./scripts/check-changelog.sh",
@@ -0,0 +1,134 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const { execFileSync } = require("node:child_process");
5
+
6
+ const LOG_PREFIX = "[codeharbor postinstall]";
7
+ const MAIN_SERVICE = "codeharbor.service";
8
+ const ADMIN_SERVICE = "codeharbor-admin.service";
9
+
10
+ function isTruthy(value) {
11
+ if (!value) {
12
+ return false;
13
+ }
14
+ const normalized = String(value).trim().toLowerCase();
15
+ return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
16
+ }
17
+
18
+ function isGlobalInstall() {
19
+ return isTruthy(process.env.npm_config_global);
20
+ }
21
+
22
+ function hasRootPrivileges() {
23
+ if (typeof process.getuid !== "function") {
24
+ return true;
25
+ }
26
+ return process.getuid() === 0;
27
+ }
28
+
29
+ function commandExists(file) {
30
+ try {
31
+ execFileSync(file, ["--version"], { stdio: "ignore" });
32
+ return true;
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+
38
+ function runCommand(file, args, options) {
39
+ return execFileSync(file, args, {
40
+ encoding: "utf8",
41
+ stdio: ["ignore", "pipe", "pipe"],
42
+ ...options,
43
+ });
44
+ }
45
+
46
+ function listUnitFiles(unitName) {
47
+ try {
48
+ return runCommand("systemctl", ["list-unit-files", unitName, "--no-legend", "--no-pager"]);
49
+ } catch {
50
+ return "";
51
+ }
52
+ }
53
+
54
+ function isUnitInstalled(unitName) {
55
+ const output = listUnitFiles(unitName).trim();
56
+ if (!output) {
57
+ return false;
58
+ }
59
+ return output.split(/\r?\n/).some((line) => line.trim().startsWith(unitName));
60
+ }
61
+
62
+ function isUnitActive(unitName) {
63
+ try {
64
+ const output = runCommand("systemctl", ["is-active", unitName], {}).trim();
65
+ return output === "active";
66
+ } catch {
67
+ return false;
68
+ }
69
+ }
70
+
71
+ function restartUnit(unitName) {
72
+ if (hasRootPrivileges()) {
73
+ runCommand("systemctl", ["restart", unitName], {});
74
+ return;
75
+ }
76
+
77
+ runCommand("sudo", ["-n", "systemctl", "restart", unitName], {});
78
+ }
79
+
80
+ function main() {
81
+ if (isTruthy(process.env.CODEHARBOR_SKIP_POSTINSTALL_RESTART)) {
82
+ console.log(`${LOG_PREFIX} skip restart: CODEHARBOR_SKIP_POSTINSTALL_RESTART is set.`);
83
+ return;
84
+ }
85
+
86
+ const forceRestart = isTruthy(process.env.CODEHARBOR_FORCE_POSTINSTALL_RESTART);
87
+ if (!forceRestart && !isGlobalInstall()) {
88
+ return;
89
+ }
90
+
91
+ if (process.platform !== "linux") {
92
+ return;
93
+ }
94
+
95
+ if (!commandExists("systemctl")) {
96
+ return;
97
+ }
98
+
99
+ const candidates = [MAIN_SERVICE, ADMIN_SERVICE].filter((unitName) => isUnitInstalled(unitName));
100
+ const activeUnits = candidates.filter((unitName) => isUnitActive(unitName));
101
+ if (activeUnits.length === 0) {
102
+ return;
103
+ }
104
+
105
+ const restarted = [];
106
+ const failed = [];
107
+
108
+ for (const unitName of activeUnits) {
109
+ try {
110
+ restartUnit(unitName);
111
+ restarted.push(unitName);
112
+ } catch (error) {
113
+ failed.push({ unitName, error });
114
+ }
115
+ }
116
+
117
+ if (restarted.length > 0) {
118
+ console.log(`${LOG_PREFIX} restarted: ${restarted.join(", ")}`);
119
+ }
120
+ if (failed.length > 0) {
121
+ for (const failure of failed) {
122
+ const message = failure.error instanceof Error ? failure.error.message : String(failure.error);
123
+ console.warn(`${LOG_PREFIX} failed to restart ${failure.unitName}: ${message}`);
124
+ }
125
+ console.warn(`${LOG_PREFIX} run "codeharbor service restart --with-admin" manually if needed.`);
126
+ }
127
+ }
128
+
129
+ try {
130
+ main();
131
+ } catch (error) {
132
+ const message = error instanceof Error ? error.message : String(error);
133
+ console.warn(`${LOG_PREFIX} unexpected error: ${message}`);
134
+ }