handsoff 0.0.1-beta.2 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -160,8 +160,18 @@ var DEFAULT_CONFIG = {
160
160
  permission: {
161
161
  lowRiskTools: ["Read", "Glob", "Grep", "List", "WebSearch", "codesearch"]
162
162
  }
163
+ },
164
+ codex: {
165
+ enabled: false,
166
+ command: ["codex", "app-server", "--listen", "stdio://"],
167
+ work_dir: "",
168
+ model: "",
169
+ approval_policy: "",
170
+ sandbox_mode: ""
163
171
  }
164
- }
172
+ },
173
+ channels: {},
174
+ bindings: {}
165
175
  };
166
176
  function getConfigPath() {
167
177
  return join2(homedir2(), ".handsoff", "config.toml");
@@ -218,7 +228,31 @@ function mergeWithDefaults(raw) {
218
228
  ...DEFAULT_CONFIG.agent.claude.permission,
219
229
  ...raw.agent?.claude?.permission || {}
220
230
  }
231
+ },
232
+ codex: {
233
+ ...DEFAULT_CONFIG.agent.codex,
234
+ ...raw.agent?.codex || {},
235
+ command: raw.agent?.codex?.command ? raw.agent.codex.command : DEFAULT_CONFIG.agent.codex.command,
236
+ work_dir: raw.agent?.codex?.work_dir || DEFAULT_CONFIG.agent.codex.work_dir,
237
+ model: raw.agent?.codex?.model || DEFAULT_CONFIG.agent.codex.model,
238
+ approval_policy: raw.agent?.codex?.approval_policy || DEFAULT_CONFIG.agent.codex.approval_policy,
239
+ sandbox_mode: raw.agent?.codex?.sandbox_mode || DEFAULT_CONFIG.agent.codex.sandbox_mode
221
240
  }
241
+ },
242
+ channels: {
243
+ ...DEFAULT_CONFIG.channels,
244
+ ...raw.channels || {},
245
+ app: raw.channels?.app ? {
246
+ enabled: raw.channels.app.enabled === true,
247
+ channel_id: String(raw.channels.app.channel_id || ""),
248
+ auth_token: String(raw.channels.app.auth_token || ""),
249
+ heartbeat_interval_ms: raw.channels.app.heartbeat_interval_ms ? parseInt(String(raw.channels.app.heartbeat_interval_ms), 10) : void 0,
250
+ notify_types: Array.isArray(raw.channels.app.notify_types) ? raw.channels.app.notify_types.map(String) : void 0
251
+ } : DEFAULT_CONFIG.channels?.app
252
+ },
253
+ bindings: {
254
+ ...DEFAULT_CONFIG.bindings,
255
+ ...raw.bindings || {}
222
256
  }
223
257
  };
224
258
  }
@@ -285,6 +319,17 @@ var en_default = {
285
319
  required: "app_id and app_secret are required. Skipping."
286
320
  }
287
321
  },
322
+ codex: {
323
+ enable: "Enable Codex agent integration?",
324
+ command: "Codex app-server command:",
325
+ workDir: "Default Codex working directory (optional):",
326
+ model: "Default Codex model (optional):",
327
+ approvalPolicy: "Default Codex approval policy (optional):",
328
+ detected: "Codex CLI detected.",
329
+ notDetected: "Codex CLI not found in PATH.",
330
+ enabled: "Codex integration enabled.",
331
+ disabled: "Codex integration disabled."
332
+ },
288
333
  notify: {
289
334
  title: "Notification types for {{channel}}",
290
335
  checkbox: "Notification types:",
@@ -415,6 +460,24 @@ var en_default = {
415
460
  tipStop: 'Use "handsoff stop" to stop the daemon and restore settings',
416
461
  detaching: "\nDetaching from Claude..."
417
462
  },
463
+ codex: {
464
+ notInstalled: "Codex CLI is not installed",
465
+ installMethods: "Please install Codex CLI using one of these methods:",
466
+ option1npm: "Option 1 - npm (recommended): npm install -g @openai/codex",
467
+ option2brew: "Option 2 - Homebrew (macOS): brew install --cask codex",
468
+ alternative: "Alternatively, use Claude Code: handsoff claude",
469
+ alreadyRunning: "Handsoff daemon is already running",
470
+ starting: "Starting handsoff daemon...",
471
+ started: "Daemon started",
472
+ startFailed: "Failed to start daemon",
473
+ notEnabled: "Codex adapter is not enabled in handsoff config.",
474
+ enableHint: 'Enable it with "handsoff init" or by setting agent.codex.enabled = true in ~/.handsoff/config.toml, then restart the daemon.',
475
+ startingIn: "Starting Codex CLI {{version}} in {{dir}}",
476
+ pressCtrlC: "Press Ctrl+C to stop (handsoff daemon will keep running)",
477
+ exited: "\nCodex CLI exited with code {{code}}",
478
+ daemonRunning: "Handsoff daemon is still running in the background",
479
+ detaching: "\nDetaching from Codex..."
480
+ },
418
481
  debug: {
419
482
  starting: "Starting hook debug server on port {{port}}",
420
483
  capturing: "This will capture all incoming webhook requests...",
@@ -565,9 +628,10 @@ import { homedir as homedir7 } from "os";
565
628
  // src/cli/wizard/state.ts
566
629
  function createInitialState(hasExistingConfig) {
567
630
  return {
568
- current: "cli-menu",
631
+ current: "agent-select",
569
632
  hasExistingConfig,
570
633
  pendingChanges: {},
634
+ bindings: {},
571
635
  itemStatus: /* @__PURE__ */ new Map(),
572
636
  sessionConfigured: /* @__PURE__ */ new Set(),
573
637
  validationResults: /* @__PURE__ */ new Map()
@@ -580,24 +644,42 @@ import { join as join4 } from "path";
580
644
  import { homedir as homedir4 } from "os";
581
645
  import TOML2 from "@iarna/toml";
582
646
  var ConfigApplicator = class {
583
- apply(pending) {
647
+ apply(pending, bindings) {
584
648
  const current = loadConfig();
585
649
  const merged = {
586
650
  ...current,
587
651
  general: { ...current.general },
588
652
  channel: {
589
653
  ...current.channel,
590
- ...pending.channel || {}
654
+ logger: pending.channel?.logger ? { ...current.channel?.logger ?? {}, ...pending.channel.logger } : current.channel?.logger,
655
+ telegram: pending.channel?.telegram ? { ...current.channel?.telegram ?? {}, ...pending.channel.telegram } : current.channel?.telegram,
656
+ feishu: pending.channel?.feishu ? { ...current.channel?.feishu ?? {}, ...pending.channel.feishu } : current.channel?.feishu,
657
+ permission: pending.channel?.permission ? { ...current.channel?.permission ?? {}, ...pending.channel.permission } : current.channel?.permission
591
658
  },
592
659
  agent: {
593
660
  ...current.agent,
594
- ...pending.agent ? { claude: { ...current.agent?.claude ?? {}, ...pending.agent } } : {}
661
+ ...pending.agent?.claude ? {
662
+ claude: { ...current.agent?.claude ?? {}, ...pending.agent.claude }
663
+ } : {},
664
+ ...pending.agent?.codex ? {
665
+ codex: { ...current.agent?.codex ?? {}, ...pending.agent.codex }
666
+ } : {}
595
667
  }
596
668
  };
669
+ let finalConfig = merged;
670
+ if (bindings && Object.keys(bindings).length > 0) {
671
+ finalConfig = {
672
+ ...merged,
673
+ bindings: {
674
+ ...merged.bindings || {},
675
+ ...bindings
676
+ }
677
+ };
678
+ }
597
679
  const configDir = join4(homedir4(), ".handsoff");
598
680
  mkdirSync3(configDir, { recursive: true });
599
681
  const configPath = join4(configDir, "config.toml");
600
- const content = TOML2.stringify(merged);
682
+ const content = TOML2.stringify(finalConfig);
601
683
  writeFileSync3(configPath, content);
602
684
  }
603
685
  };
@@ -695,6 +777,34 @@ function extractHandsoffHookInfo(entry) {
695
777
  return { isHandsoff: false };
696
778
  }
697
779
 
780
+ // src/cli/wizard/detectors/codex.ts
781
+ import { execSync as execSync2 } from "child_process";
782
+ function detectCodex() {
783
+ let binaryPath;
784
+ let installed = false;
785
+ let version;
786
+ try {
787
+ binaryPath = execSync2("which codex", { encoding: "utf-8" }).trim();
788
+ installed = !!binaryPath;
789
+ version = execSync2("codex --version", { encoding: "utf-8", stdio: "pipe" }).trim();
790
+ } catch {
791
+ }
792
+ if (!installed) {
793
+ return {
794
+ supported: false,
795
+ installed: false,
796
+ message: "Codex CLI not found in PATH. Please install Codex CLI first."
797
+ };
798
+ }
799
+ return {
800
+ supported: true,
801
+ installed: true,
802
+ message: `Codex CLI ${version} detected.`,
803
+ binaryPath,
804
+ version
805
+ };
806
+ }
807
+
698
808
  // src/cli/wizard/prompts.ts
699
809
  import { confirm, input, checkbox, select } from "@inquirer/prompts";
700
810
  var prompts = {
@@ -710,6 +820,26 @@ var prompts = {
710
820
  message: t("wizard.channel.feishu.allowedUsers"),
711
821
  default: current || ""
712
822
  }),
823
+ codexEnable: (enabled) => confirm({
824
+ message: t("wizard.codex.enable"),
825
+ default: enabled
826
+ }),
827
+ codexCommand: (current) => input({
828
+ message: t("wizard.codex.command"),
829
+ default: current || "codex app-server --listen stdio://"
830
+ }),
831
+ codexWorkDir: (current) => input({
832
+ message: t("wizard.codex.workDir"),
833
+ default: current || ""
834
+ }),
835
+ codexModel: (current) => input({
836
+ message: t("wizard.codex.model"),
837
+ default: current || ""
838
+ }),
839
+ codexApprovalPolicy: (current) => input({
840
+ message: t("wizard.codex.approvalPolicy"),
841
+ default: current || ""
842
+ }),
713
843
  cliActionSelect: (hooked) => select({
714
844
  message: t("wizard.cli.actionQuestion"),
715
845
  choices: hooked ? [
@@ -774,6 +904,32 @@ var prompts = {
774
904
  restartGateway: () => confirm({
775
905
  message: t("wizard.menu.restartQuestion"),
776
906
  default: true
907
+ }),
908
+ bindingAgentSelect: (agents) => select({
909
+ message: "Select an agent to bind:",
910
+ choices: agents.map((a) => ({ name: a.name, value: a.value }))
911
+ }),
912
+ bindingConfirm: (agentName, channelName) => select({
913
+ message: `\u{1F916} Bind ${agentName} to ${channelName} \u2014 confirm?`,
914
+ choices: [
915
+ { name: "Confirm binding", value: "confirm" },
916
+ { name: "Cancel", value: "cancel" }
917
+ ]
918
+ }),
919
+ pairContinue: () => select({
920
+ message: "Add another pair?",
921
+ choices: [
922
+ { name: "Add another pair", value: "continue" },
923
+ { name: "Finish setup", value: "finish" }
924
+ ]
925
+ }),
926
+ agentSelect: (agents) => select({
927
+ message: "Select an agent to configure:",
928
+ choices: agents.map((a) => ({ name: `${a.name} ${a.status}`, value: a.value }))
929
+ }),
930
+ channelSelect: (channels) => select({
931
+ message: "Select a channel to configure:",
932
+ choices: channels.map((c) => ({ name: `${c.name} ${c.status}`, value: c.value }))
777
933
  })
778
934
  };
779
935
 
@@ -782,10 +938,13 @@ import { writeFileSync as writeFileSync4, existsSync as existsSync5, readFileSyn
782
938
  import { dirname as dirname3 } from "path";
783
939
  import pc from "picocolors";
784
940
  import ora from "ora";
941
+ var STEP = "STEP 2";
785
942
  async function stepCli(state) {
786
943
  const cliName = state.currentCliName || "claude";
787
944
  console.log(pc.bold(`
788
- ${t("wizard.section.cli")}
945
+ ${STEP} \u2014 Configure Agent
946
+ `));
947
+ console.log(pc.gray(`${"\u2500".repeat(50)}
789
948
  `));
790
949
  if (cliName === "claude") {
791
950
  const detection = state.claudeDetection ?? detectClaude();
@@ -825,16 +984,77 @@ ${t("wizard.section.cli")}
825
984
  spinner.succeed(t("wizard.cli.injected"));
826
985
  state.itemStatus.set("claude", "configured");
827
986
  state.sessionConfigured.add("claude");
828
- state.pendingChanges.agent = { adapter: "hooks" };
987
+ state.pendingChanges.agent = {
988
+ ...state.pendingChanges.agent || {},
989
+ claude: { adapter: "hooks" }
990
+ };
829
991
  state.validationResults.set("claude", { ok: true });
992
+ return { action: "next", next: "channel-select" };
830
993
  } catch (err) {
831
994
  spinner.fail(t("wizard.cli.failed", { error: String(err) }));
832
995
  state.itemStatus.set("claude", "error");
833
996
  state.validationResults.set("claude", { ok: false, message: String(err) });
834
997
  }
835
998
  }
999
+ if (cliName === "codex") {
1000
+ const detection = state.codexDetection ?? detectCodex();
1001
+ const config = loadConfig();
1002
+ const current = config.agent.codex;
1003
+ if (!detection.installed) {
1004
+ console.log(pc.yellow(`! ${t("wizard.codex.notDetected")}`));
1005
+ console.log(pc.gray(` ${detection.message}
1006
+ `));
1007
+ state.itemStatus.set("codex", "not-found");
1008
+ state.validationResults.set("codex", { ok: false, message: detection.message });
1009
+ return { action: "next", next: "cli-menu" };
1010
+ }
1011
+ console.log(pc.green(`* ${t("wizard.codex.detected")}`));
1012
+ console.log(pc.gray(` Binary: ${detection.binaryPath}`));
1013
+ console.log(pc.gray(` Version: ${detection.version || "unknown"}`));
1014
+ console.log("");
1015
+ const enabled = await prompts.codexEnable(current?.enabled === true);
1016
+ if (!enabled) {
1017
+ state.pendingChanges.agent = {
1018
+ ...state.pendingChanges.agent || {},
1019
+ codex: {
1020
+ ...current || {},
1021
+ enabled: false
1022
+ }
1023
+ };
1024
+ state.itemStatus.set("codex", "unconfigured");
1025
+ state.validationResults.set("codex", { ok: true, message: t("wizard.codex.disabled") });
1026
+ console.log(pc.gray(` ${t("wizard.codex.disabled")}
1027
+ `));
1028
+ return { action: "next", next: "cli-menu" };
1029
+ }
1030
+ const commandInput = await prompts.codexCommand((current?.command || []).join(" "));
1031
+ const workDir = await prompts.codexWorkDir(current?.work_dir || "");
1032
+ const model = await prompts.codexModel(current?.model || "");
1033
+ const approvalPolicy = await prompts.codexApprovalPolicy(current?.approval_policy || "");
1034
+ state.pendingChanges.agent = {
1035
+ ...state.pendingChanges.agent || {},
1036
+ codex: {
1037
+ ...current || {},
1038
+ enabled: true,
1039
+ command: parseCommandInput(commandInput),
1040
+ work_dir: workDir,
1041
+ model,
1042
+ approval_policy: approvalPolicy
1043
+ }
1044
+ };
1045
+ state.itemStatus.set("codex", "configured");
1046
+ state.sessionConfigured.add("codex");
1047
+ state.validationResults.set("codex", { ok: true, message: t("wizard.codex.enabled") });
1048
+ console.log(pc.green(` ${t("wizard.codex.enabled")}
1049
+ `));
1050
+ return { action: "next", next: "channel-select" };
1051
+ }
836
1052
  return { action: "next", next: "cli-menu" };
837
1053
  }
1054
+ function parseCommandInput(input2) {
1055
+ const tokens = input2.trim().split(/\s+/).filter(Boolean);
1056
+ return tokens.length > 0 ? tokens : ["codex", "app-server", "--listen", "stdio://"];
1057
+ }
838
1058
  function injectClaudeHooks(settingsPath, events) {
839
1059
  const port = loadConfig().general.hook_server_port;
840
1060
  const token = getOrCreateHookToken();
@@ -908,6 +1128,10 @@ function getChannelStatusLabel(status, error) {
908
1128
  switch (status) {
909
1129
  case "configured":
910
1130
  return "[done]";
1131
+ case "detected":
1132
+ return "[detected]";
1133
+ case "not-found":
1134
+ return "[not found]";
911
1135
  case "error":
912
1136
  return `[error]${error ? ` (${error})` : ""}`;
913
1137
  default:
@@ -917,39 +1141,22 @@ function getChannelStatusLabel(status, error) {
917
1141
 
918
1142
  // src/cli/wizard/steps/channel-menu.ts
919
1143
  import pc2 from "picocolors";
920
- async function stepChannelMenu(state) {
921
- console.log(pc2.bold(`
922
- ${t("wizard.section.channel")}
923
- `));
924
- const channelNames = ["logger", "telegram", "feishu"];
925
- const channels = channelNames.map((name) => {
926
- const status = state.itemStatus.get(name) || "unconfigured";
927
- const validation = state.validationResults.get(name);
928
- return {
929
- name,
930
- status: getChannelStatusLabel(status, validation?.message)
931
- };
932
- });
933
- const choice = await prompts.channelMenuSelect(channels);
934
- if (choice === "__next__") {
935
- return { action: "next", next: "final" };
936
- }
937
- state.currentChannelName = choice;
938
- return { action: "next", next: "channel-config" };
939
- }
940
1144
  async function stepCliMenu(state) {
941
1145
  console.log(pc2.bold(`
942
1146
  ${t("wizard.section.cli")}
943
1147
  `));
944
- const cliStatus = state.itemStatus.get("claude") || "unconfigured";
945
- let label = getChannelStatusLabel(cliStatus);
1148
+ const claudeStatus = state.itemStatus.get("claude") || "unconfigured";
1149
+ let claudeLabel = getChannelStatusLabel(claudeStatus);
946
1150
  if (state.claudeDetection?.tokenMismatch) {
947
- label = pc2.yellow("[update needed]");
948
- }
949
- const choice = await prompts.channelMenuSelect([{
950
- name: "claude",
951
- status: label
952
- }]);
1151
+ claudeLabel = pc2.yellow("[update needed]");
1152
+ }
1153
+ const codexStatus = state.itemStatus.get("codex") || "not-found";
1154
+ const codexLabel = getChannelStatusLabel(codexStatus, state.validationResults.get("codex")?.message);
1155
+ const cliOptions = [
1156
+ { name: "claude", status: claudeLabel },
1157
+ { name: "codex", status: codexLabel }
1158
+ ];
1159
+ const choice = await prompts.channelMenuSelect(cliOptions);
953
1160
  if (choice === "__next__") {
954
1161
  return { action: "next", next: "channel-menu" };
955
1162
  }
@@ -960,10 +1167,13 @@ ${t("wizard.section.cli")}
960
1167
  // src/cli/wizard/steps/channel-config.ts
961
1168
  import pc3 from "picocolors";
962
1169
  import ora2 from "ora";
1170
+ var STEP2 = "STEP 4";
963
1171
  async function stepChannelConfig(state) {
964
1172
  const channelName = state.currentChannelName || "telegram";
965
1173
  console.log(pc3.bold(`
966
- ${t("wizard.channel.configuring", { channel: channelName })}
1174
+ ${STEP2} \u2014 Configure Channel
1175
+ `));
1176
+ console.log(pc3.gray(`${"\u2500".repeat(50)}
967
1177
  `));
968
1178
  while (true) {
969
1179
  const cfg = loadConfig();
@@ -995,7 +1205,7 @@ ${t("wizard.channel.configuring", { channel: channelName })}
995
1205
  continue;
996
1206
  }
997
1207
  state.itemStatus.set(channelName, "unconfigured");
998
- return { action: "next", next: "channel-menu" };
1208
+ return { action: "next", next: "channel-select" };
999
1209
  }
1000
1210
  const msgSpinner = ora2(t("wizard.channel.telegram.sendingTest")).start();
1001
1211
  try {
@@ -1016,7 +1226,7 @@ ${t("wizard.channel.configuring", { channel: channelName })}
1016
1226
  state.itemStatus.set("telegram", "configured");
1017
1227
  state.sessionConfigured.add("telegram");
1018
1228
  state.validationResults.set("telegram", { ok: true });
1019
- return { action: "next", next: "channel-notify" };
1229
+ return { action: "next", next: "binding-confirm" };
1020
1230
  }
1021
1231
  if (channelName === "logger") {
1022
1232
  state.pendingChanges.channel = {
@@ -1025,7 +1235,7 @@ ${t("wizard.channel.configuring", { channel: channelName })}
1025
1235
  };
1026
1236
  state.itemStatus.set("logger", "configured");
1027
1237
  state.sessionConfigured.add("logger");
1028
- return { action: "next", next: "channel-notify" };
1238
+ return { action: "next", next: "binding-confirm" };
1029
1239
  }
1030
1240
  if (channelName === "feishu") {
1031
1241
  const appId = await prompts.feishuAppId(String(currentConfig.app_id || ""));
@@ -1035,7 +1245,7 @@ ${t("wizard.channel.configuring", { channel: channelName })}
1035
1245
  console.log(pc3.yellow(`! ${t("wizard.channel.feishu.required")}
1036
1246
  `));
1037
1247
  state.itemStatus.set("feishu", "unconfigured");
1038
- return { action: "next", next: "channel-menu" };
1248
+ return { action: "next", next: "channel-select" };
1039
1249
  }
1040
1250
  const allowedUsers = allowedUsersInput.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
1041
1251
  state.pendingChanges.channel = {
@@ -1044,9 +1254,9 @@ ${t("wizard.channel.configuring", { channel: channelName })}
1044
1254
  };
1045
1255
  state.itemStatus.set("feishu", "configured");
1046
1256
  state.sessionConfigured.add("feishu");
1047
- return { action: "next", next: "channel-notify" };
1257
+ return { action: "next", next: "binding-confirm" };
1048
1258
  }
1049
- return { action: "next", next: "channel-menu" };
1259
+ return { action: "next", next: "agent-select" };
1050
1260
  }
1051
1261
  }
1052
1262
 
@@ -1073,6 +1283,167 @@ ${t("wizard.notify.title", { channel: channelName })}
1073
1283
  return { action: "next", next: "channel-menu" };
1074
1284
  }
1075
1285
 
1286
+ // src/cli/wizard/steps/agent-select.ts
1287
+ import pc5 from "picocolors";
1288
+ var STEP3 = "STEP 1";
1289
+ async function stepAgentSelect(state) {
1290
+ console.log(pc5.bold(`
1291
+ ${STEP3} \u2014 Select Agent
1292
+ `));
1293
+ console.log(pc5.gray(`${"\u2500".repeat(50)}
1294
+ `));
1295
+ const config = loadConfig();
1296
+ const currentBindings = config.bindings ?? {};
1297
+ const agents = [];
1298
+ const claudeConfigured = !!config.agent.claude;
1299
+ const claudeBoundTo = currentBindings["claude"];
1300
+ const claudeStatus = claudeBoundTo ? pc5.green(`[bound to ${claudeBoundTo}]`) : claudeConfigured ? pc5.green("[configured]") : pc5.gray("[not configured]");
1301
+ agents.push({ name: "claude", value: "claude", status: claudeStatus });
1302
+ const codexDetection = detectCodex();
1303
+ const codexConfigured = !!config.agent.codex?.enabled;
1304
+ const codexBoundTo = currentBindings["codex"];
1305
+ const codexStatus = codexBoundTo ? pc5.green(`[bound to ${codexBoundTo}]`) : codexConfigured ? pc5.green("[configured]") : codexDetection.installed ? pc5.gray("[not configured]") : pc5.red("[not installed]");
1306
+ agents.push({ name: "codex", value: "codex", status: codexStatus });
1307
+ const choice = await prompts.agentSelect(agents);
1308
+ state.currentAgentName = choice;
1309
+ state.currentCliName = choice;
1310
+ const result = await stepCli(state);
1311
+ if (result.action === "back") {
1312
+ return { action: "next", next: "pair-continue" };
1313
+ }
1314
+ if (result.next === "cli-menu") {
1315
+ return { action: "next", next: "agent-select" };
1316
+ }
1317
+ return { action: "next", next: "channel-select" };
1318
+ }
1319
+
1320
+ // src/shared/channelInstance.ts
1321
+ import { createHash } from "crypto";
1322
+ function getChannelInstanceId(channelType, credential) {
1323
+ const hash = createHash("sha256").update(credential).digest("hex").slice(0, 12);
1324
+ return `${channelType}:${hash}`;
1325
+ }
1326
+
1327
+ // src/cli/wizard/steps/channel-select.ts
1328
+ import pc6 from "picocolors";
1329
+ var STEP4 = "STEP 3";
1330
+ async function stepChannelSelect(state) {
1331
+ console.log(pc6.bold(`
1332
+ ${STEP4} \u2014 Select Channel
1333
+ `));
1334
+ console.log(pc6.gray(`${"\u2500".repeat(50)}
1335
+ `));
1336
+ const config = loadConfig();
1337
+ const currentBindings = config.bindings ?? {};
1338
+ const pendingChannels = state.pendingChanges.channel ?? {};
1339
+ const mergedTelegram = pendingChannels.telegram ? { ...config.channel.telegram, ...pendingChannels.telegram } : config.channel.telegram;
1340
+ const mergedFeishu = pendingChannels.feishu ? { ...config.channel.feishu, ...pendingChannels.feishu } : config.channel.feishu;
1341
+ const mergedLogger = pendingChannels.logger ? { ...config.channel.logger, ...pendingChannels.logger } : config.channel.logger;
1342
+ const channels = [];
1343
+ const loggerEnabled = mergedLogger?.enabled === true;
1344
+ const loggerInstanceId = "logger:default";
1345
+ const loggerBoundAgent = Object.entries(currentBindings).find(([, v]) => v === loggerInstanceId)?.[0];
1346
+ const loggerStatus = loggerBoundAgent ? pc6.green(`[bound to ${loggerBoundAgent}]`) : loggerEnabled ? pc6.green("[configured]") : pc6.gray("[not configured]");
1347
+ channels.push({ name: "logger", value: "logger", status: loggerStatus });
1348
+ if (mergedTelegram?.enabled && mergedTelegram.bot_token) {
1349
+ const instanceId = getChannelInstanceId("telegram", mergedTelegram.bot_token);
1350
+ const boundAgent = Object.entries(currentBindings).find(([, v]) => v === instanceId)?.[0];
1351
+ const status = boundAgent ? pc6.green(`[bound to ${boundAgent}]`) : pc6.green("[configured]");
1352
+ channels.push({ name: "telegram", value: "telegram", status });
1353
+ } else {
1354
+ channels.push({ name: "telegram", value: "telegram", status: pc6.gray("[not configured]") });
1355
+ }
1356
+ if (mergedFeishu?.enabled && mergedFeishu.app_id) {
1357
+ const instanceId = getChannelInstanceId("feishu", mergedFeishu.app_id);
1358
+ const boundAgent = Object.entries(currentBindings).find(([, v]) => v === instanceId)?.[0];
1359
+ const status = boundAgent ? pc6.green(`[bound to ${boundAgent}]`) : pc6.green("[configured]");
1360
+ channels.push({ name: "feishu", value: "feishu", status });
1361
+ } else {
1362
+ channels.push({ name: "feishu", value: "feishu", status: pc6.gray("[not configured]") });
1363
+ }
1364
+ const choice = await prompts.channelSelect(channels);
1365
+ state.currentChannelName = choice;
1366
+ const result = await stepChannelConfig(state);
1367
+ if (result.action === "back") {
1368
+ return { action: "next", next: "agent-select" };
1369
+ }
1370
+ return { action: "next", next: "binding-confirm" };
1371
+ }
1372
+
1373
+ // src/cli/wizard/steps/binding-confirm.ts
1374
+ import pc7 from "picocolors";
1375
+ var STEP5 = "STEP 5";
1376
+ async function stepBindingConfirm(state) {
1377
+ const agentName = state.currentAgentName;
1378
+ const channelName = state.currentChannelName;
1379
+ console.log(pc7.bold(`
1380
+ ${STEP5} \u2014 Confirm Binding
1381
+ `));
1382
+ console.log(pc7.gray(`${"\u2500".repeat(50)}
1383
+ `));
1384
+ const config = loadConfig();
1385
+ const currentBindings = { ...config.bindings ?? {} };
1386
+ for (const [a, cid] of Object.entries(state.bindings)) {
1387
+ if (cid) currentBindings[a] = cid;
1388
+ }
1389
+ const pendingChannel = state.pendingChanges.channel?.[channelName];
1390
+ const telegramConfig = pendingChannel && channelName === "telegram" ? { ...config.channel.telegram, ...pendingChannel } : config.channel.telegram;
1391
+ const feishuConfig = pendingChannel && channelName === "feishu" ? { ...config.channel.feishu, ...pendingChannel } : config.channel.feishu;
1392
+ const agentCurrentChannel = currentBindings[agentName];
1393
+ let channelCurrentAgent;
1394
+ let instanceId;
1395
+ if (channelName === "telegram" && telegramConfig?.bot_token) {
1396
+ instanceId = getChannelInstanceId("telegram", telegramConfig.bot_token);
1397
+ channelCurrentAgent = Object.entries(currentBindings).find(([, v]) => v === instanceId)?.[0];
1398
+ } else if (channelName === "feishu" && feishuConfig?.app_id) {
1399
+ instanceId = getChannelInstanceId("feishu", feishuConfig.app_id);
1400
+ channelCurrentAgent = Object.entries(currentBindings).find(([, v]) => v === instanceId)?.[0];
1401
+ } else if (channelName === "logger") {
1402
+ instanceId = "logger:default";
1403
+ channelCurrentAgent = Object.entries(currentBindings).find(([, v]) => v === instanceId)?.[0];
1404
+ }
1405
+ console.log(` ${pc7.cyan(agentName)} current binding: ${agentCurrentChannel ? pc7.yellow(agentCurrentChannel) : pc7.gray("none")}`);
1406
+ console.log(` ${pc7.cyan(channelName)} current binding: ${channelCurrentAgent ? pc7.yellow(channelCurrentAgent) : pc7.gray("none")}`);
1407
+ console.log("");
1408
+ const choice = await prompts.bindingConfirm(agentName, channelName);
1409
+ if (choice === "confirm" && instanceId) {
1410
+ const oldChannelId = currentBindings[agentName];
1411
+ for (const [a, cid] of Object.entries(currentBindings)) {
1412
+ if (cid === instanceId && a !== agentName) {
1413
+ delete state.bindings[a];
1414
+ }
1415
+ }
1416
+ if (oldChannelId && oldChannelId !== instanceId) {
1417
+ console.log(pc7.yellow(` \u26A0 ${agentName} migrated from ${oldChannelId} to ${instanceId}`));
1418
+ }
1419
+ state.bindings[agentName] = instanceId;
1420
+ state.sessionConfigured.add(`binding:${agentName}`);
1421
+ console.log(pc7.green(` \u2713 ${agentName} bound to ${channelName}`));
1422
+ } else {
1423
+ console.log(pc7.gray(` Binding cancelled`));
1424
+ }
1425
+ console.log("");
1426
+ return { action: "next", next: "pair-continue" };
1427
+ }
1428
+
1429
+ // src/cli/wizard/steps/pair-continue.ts
1430
+ import pc8 from "picocolors";
1431
+ var STEP6 = "STEP 6";
1432
+ async function stepPairContinue(state) {
1433
+ console.log(pc8.bold(`
1434
+ ${STEP6} \u2014 Add Another Pair?
1435
+ `));
1436
+ console.log(pc8.gray(`${"\u2500".repeat(50)}
1437
+ `));
1438
+ const choice = await prompts.pairContinue();
1439
+ if (choice === "continue") {
1440
+ state.currentAgentName = void 0;
1441
+ state.currentChannelName = void 0;
1442
+ return { action: "next", next: "agent-select" };
1443
+ }
1444
+ return { action: "next", next: "final" };
1445
+ }
1446
+
1076
1447
  // src/cli/daemon.ts
1077
1448
  import { spawn } from "child_process";
1078
1449
  import { exec } from "child_process";
@@ -1155,14 +1526,14 @@ var DaemonManager = class {
1155
1526
  }
1156
1527
  }
1157
1528
  try {
1158
- const homedir14 = process.env.HOME || process.env.USERPROFILE || "";
1159
- const logDir = join6(homedir14, ".handsoff", "logs");
1529
+ const homedir15 = process.env.HOME || process.env.USERPROFILE || "";
1530
+ const logDir = join6(homedir15, ".handsoff", "logs");
1160
1531
  mkdirSync5(logDir, { recursive: true });
1161
1532
  removePidFile(this.pidFilePath);
1162
1533
  const __dirname3 = dirname4(fileURLToPath(import.meta.url));
1163
- let daemonScript = join6(__dirname3, "..", "daemon", "process.js");
1534
+ let daemonScript = join6(__dirname3, "..", "gateway", "process.js");
1164
1535
  if (!existsSync7(daemonScript)) {
1165
- daemonScript = join6(__dirname3, "..", "..", "src", "daemon", "process.ts");
1536
+ daemonScript = join6(__dirname3, "..", "..", "src", "gateway", "process.ts");
1166
1537
  }
1167
1538
  if (!existsSync7(daemonScript)) {
1168
1539
  this.lastError = t("daemon.scriptNotFound", { path: daemonScript });
@@ -1244,20 +1615,20 @@ var DaemonManager = class {
1244
1615
  // src/cli/wizard/steps/final.ts
1245
1616
  import { join as join7 } from "path";
1246
1617
  import { homedir as homedir6 } from "os";
1247
- import pc5 from "picocolors";
1618
+ import pc9 from "picocolors";
1248
1619
  import ora3 from "ora";
1249
1620
  async function stepFinal(state) {
1250
- console.log(pc5.bold(`
1621
+ console.log(pc9.bold(`
1251
1622
  ${t("wizard.section.complete")}
1252
1623
  `));
1253
1624
  if (state.sessionConfigured.size === 0) {
1254
- console.log(pc5.gray(` ${t("wizard.final.noItems")}
1625
+ console.log(pc9.gray(` ${t("wizard.final.noItems")}
1255
1626
  `));
1256
1627
  } else {
1257
1628
  for (const item of state.sessionConfigured) {
1258
1629
  const result = state.validationResults.get(item);
1259
1630
  if (result?.ok) {
1260
- console.log(pc5.green(`* ${t("wizard.final.itemOk", { item })}`));
1631
+ console.log(pc9.green(`* ${t("wizard.final.itemOk", { item })}`));
1261
1632
  }
1262
1633
  }
1263
1634
  console.log("");
@@ -1265,12 +1636,12 @@ ${t("wizard.section.complete")}
1265
1636
  const pidFilePath = join7(homedir6(), ".handsoff", "handsoff.pid");
1266
1637
  const port = loadConfig().general.hook_server_port;
1267
1638
  const daemon = new DaemonManager(pidFilePath, port);
1268
- const hasChanges = state.sessionConfigured.size > 0 || Object.keys(state.pendingChanges).length > 0;
1639
+ const hasChanges = state.sessionConfigured.size > 0 || Object.keys(state.pendingChanges).length > 0 || Object.keys(state.bindings).length > 0;
1269
1640
  if (daemon.isRunning()) {
1270
1641
  if (hasChanges) {
1271
- console.log(pc5.blue(`i ${t("wizard.final.runningChanges")}
1642
+ console.log(pc9.blue(`i ${t("wizard.final.runningChanges")}
1272
1643
  `));
1273
- console.log(pc5.yellow(` ${t("wizard.final.configModified")}
1644
+ console.log(pc9.yellow(` ${t("wizard.final.configModified")}
1274
1645
  `));
1275
1646
  const shouldRestart = await prompts.restartGateway();
1276
1647
  if (shouldRestart) {
@@ -1283,20 +1654,20 @@ ${t("wizard.section.complete")}
1283
1654
  spinner.fail(t("wizard.final.startFailed", { error: daemon.getLastError() ?? "unknown" }));
1284
1655
  }
1285
1656
  } else {
1286
- console.log(pc5.gray(` ${t("wizard.final.restartTip")}
1657
+ console.log(pc9.gray(` ${t("wizard.final.restartTip")}
1287
1658
  `));
1288
1659
  }
1289
1660
  } else {
1290
- console.log(pc5.blue(`i ${t("wizard.final.running")}
1661
+ console.log(pc9.blue(`i ${t("wizard.final.running")}
1291
1662
  `));
1292
- console.log(pc5.green(` ${t("wizard.final.runningReady")}
1663
+ console.log(pc9.green(` ${t("wizard.final.runningReady")}
1293
1664
  `));
1294
1665
  }
1295
1666
  } else {
1296
- console.log(pc5.blue(`i ${t("wizard.final.notRunning")}
1667
+ console.log(pc9.blue(`i ${t("wizard.final.notRunning")}
1297
1668
  `));
1298
1669
  if (hasChanges) {
1299
- console.log(pc5.yellow(` ${t("wizard.final.configModifiedStart")}
1670
+ console.log(pc9.yellow(` ${t("wizard.final.configModifiedStart")}
1300
1671
  `));
1301
1672
  }
1302
1673
  const shouldStart = await prompts.restartGateway();
@@ -1309,11 +1680,11 @@ ${t("wizard.section.complete")}
1309
1680
  spinner2.fail(t("wizard.final.startFailed", { error: daemon.getLastError() ?? "unknown" }));
1310
1681
  }
1311
1682
  } else {
1312
- console.log(pc5.gray(` ${t("wizard.final.startTip")}
1683
+ console.log(pc9.gray(` ${t("wizard.final.startTip")}
1313
1684
  `));
1314
1685
  }
1315
1686
  }
1316
- console.log(pc5.green(`
1687
+ console.log(pc9.green(`
1317
1688
  ${t("wizard.final.done")}
1318
1689
  `));
1319
1690
  return { action: "next", next: "done" };
@@ -1333,23 +1704,30 @@ var WizardEngine = class {
1333
1704
  await this.detectAllStatus();
1334
1705
  while (this.state.current !== "done" && this.state.current !== "quit") {
1335
1706
  if (this.state.current === "final" && this.hasChanges()) {
1336
- this.applicator.apply(this.state.pendingChanges);
1707
+ this.applicator.apply(this.state.pendingChanges, this.state.bindings);
1337
1708
  }
1338
1709
  const result = await this.runStep(this.state.current);
1339
1710
  if (result.action === "next") {
1340
1711
  this.state.previous = this.state.current;
1341
1712
  this.state.current = result.next;
1342
1713
  } else if (result.action === "back") {
1343
- this.state.current = this.state.previous || "channel-menu";
1714
+ this.state.current = this.state.previous || "agent-select";
1344
1715
  } else if (result.action === "quit") {
1345
1716
  this.state.current = "quit";
1346
1717
  }
1347
1718
  }
1348
1719
  }
1349
1720
  async detectAllStatus() {
1721
+ const config = loadConfig();
1350
1722
  const claude = detectClaude();
1351
1723
  this.state.claudeDetection = claude;
1352
1724
  this.state.itemStatus.set("claude", claude.hooked ? "configured" : "unconfigured");
1725
+ const codex = detectCodex();
1726
+ this.state.codexDetection = codex;
1727
+ this.state.itemStatus.set(
1728
+ "codex",
1729
+ config.agent.codex?.enabled ? "configured" : codex.installed ? "detected" : "not-found"
1730
+ );
1353
1731
  const channelNames = ["logger", "telegram", "feishu"];
1354
1732
  for (const name of channelNames) {
1355
1733
  const detection = await detectChannel(name);
@@ -1362,12 +1740,18 @@ var WizardEngine = class {
1362
1740
  }
1363
1741
  async runStep(step) {
1364
1742
  switch (step) {
1743
+ case "agent-select":
1744
+ return await stepAgentSelect(this.state);
1745
+ case "channel-select":
1746
+ return await stepChannelSelect(this.state);
1747
+ case "binding-confirm":
1748
+ return await stepBindingConfirm(this.state);
1749
+ case "pair-continue":
1750
+ return await stepPairContinue(this.state);
1365
1751
  case "cli":
1366
1752
  return await stepCli(this.state);
1367
1753
  case "cli-menu":
1368
1754
  return await stepCliMenu(this.state);
1369
- case "channel-menu":
1370
- return await stepChannelMenu(this.state);
1371
1755
  case "channel-config":
1372
1756
  return await stepChannelConfig(this.state);
1373
1757
  case "channel-notify":
@@ -1379,7 +1763,7 @@ var WizardEngine = class {
1379
1763
  }
1380
1764
  }
1381
1765
  hasChanges() {
1382
- return this.state.sessionConfigured.size > 0 || Object.keys(this.state.pendingChanges).length > 0;
1766
+ return this.state.sessionConfigured.size > 0 || Object.keys(this.state.pendingChanges).length > 0 || Object.keys(this.state.bindings).length > 0;
1383
1767
  }
1384
1768
  };
1385
1769
 
@@ -1921,9 +2305,170 @@ function registerClaudeCommand(program2) {
1921
2305
  });
1922
2306
  }
1923
2307
 
2308
+ // src/cli/agent/codex.ts
2309
+ import { spawn as spawn4 } from "child_process";
2310
+ import { homedir as homedir14 } from "os";
2311
+ import { join as join16 } from "path";
2312
+ import { execSync as execSync3 } from "child_process";
2313
+
2314
+ // src/shared/logger.ts
2315
+ import pino from "pino";
2316
+ import { mkdirSync as mkdirSync7, existsSync as existsSync13, appendFileSync } from "fs";
2317
+ import { dirname as dirname7 } from "path";
2318
+ import { hostname } from "os";
2319
+ var LOG_LEVEL_MAP = {
2320
+ debug: 20,
2321
+ info: 30,
2322
+ warn: 40,
2323
+ error: 50
2324
+ };
2325
+ var currentLogLevel = "info" /* INFO */;
2326
+ function createSyncLogger(logFilePath, minLevel = "info" /* INFO */) {
2327
+ const hostnameValue = hostname();
2328
+ const writeLog = (level, msg, obj) => {
2329
+ try {
2330
+ const logDir = dirname7(logFilePath);
2331
+ if (!existsSync13(logDir)) {
2332
+ mkdirSync7(logDir, { recursive: true });
2333
+ }
2334
+ const logEntry = {
2335
+ level,
2336
+ time: Date.now(),
2337
+ msg,
2338
+ pid: process.pid,
2339
+ hostname: hostnameValue,
2340
+ ...obj
2341
+ };
2342
+ const line = JSON.stringify(logEntry) + "\n";
2343
+ appendFileSync(logFilePath, line);
2344
+ } catch (err) {
2345
+ console.error(`[Logger] Failed to write to ${logFilePath}:`, err);
2346
+ }
2347
+ };
2348
+ const log = (logLevel) => {
2349
+ const minLevelNum = LOG_LEVEL_MAP[minLevel] ?? LOG_LEVEL_MAP.info;
2350
+ const callLevelNum = LOG_LEVEL_MAP[logLevel] ?? LOG_LEVEL_MAP.info;
2351
+ return (obj, msg) => {
2352
+ if (callLevelNum < minLevelNum) return;
2353
+ if (typeof obj === "string") {
2354
+ writeLog(logLevel, obj);
2355
+ } else {
2356
+ writeLog(logLevel, msg || "", obj);
2357
+ }
2358
+ };
2359
+ };
2360
+ const createChild = (_bindings) => {
2361
+ return {
2362
+ info: log("info"),
2363
+ warn: log("warn"),
2364
+ error: log("error"),
2365
+ debug: log("debug"),
2366
+ child: createChild,
2367
+ level: "debug",
2368
+ levels: LOG_LEVEL_MAP
2369
+ };
2370
+ };
2371
+ return {
2372
+ info: log("info"),
2373
+ warn: log("warn"),
2374
+ error: log("error"),
2375
+ debug: log("debug"),
2376
+ child: createChild,
2377
+ level: "debug",
2378
+ levels: LOG_LEVEL_MAP
2379
+ };
2380
+ }
2381
+ function createAgentLogger(agentType) {
2382
+ const homedir15 = process.env.HOME || process.env.USERPROFILE || "";
2383
+ const logFile = `${homedir15}/.handsoff/logs/agents/${agentType}.log`;
2384
+ return createSyncLogger(logFile, currentLogLevel);
2385
+ }
2386
+
2387
+ // src/cli/agent/codex.ts
2388
+ function isCodexInstalled() {
2389
+ try {
2390
+ execSync3("codex --version", { encoding: "utf8", stdio: "pipe", windowsHide: true });
2391
+ return true;
2392
+ } catch {
2393
+ return false;
2394
+ }
2395
+ }
2396
+ function getCodexVersion() {
2397
+ try {
2398
+ return execSync3("codex --version", { encoding: "utf8", stdio: "pipe", windowsHide: true }).trim();
2399
+ } catch {
2400
+ return void 0;
2401
+ }
2402
+ }
2403
+ function registerCodexCommand(program2) {
2404
+ program2.command("codex").description("Start Codex CLI with handsoff integration").option("-d, --directory <dir>", "Working directory for Codex").action(async (options) => {
2405
+ const version = getCodexVersion();
2406
+ if (!isCodexInstalled()) {
2407
+ console.error("\n" + t("cli.codex.notInstalled") + "\n");
2408
+ console.error(t("cli.codex.installMethods") + "\n");
2409
+ console.error(t("cli.codex.option1npm") + "\n");
2410
+ console.error(t("cli.codex.option2brew") + "\n");
2411
+ console.error(t("cli.codex.alternative") + "\n");
2412
+ process.exit(1);
2413
+ }
2414
+ const config = loadConfig();
2415
+ const port = config.general.hook_server_port;
2416
+ const pidFilePath = join16(homedir14(), ".handsoff", "handsoff.pid");
2417
+ const daemon = new DaemonManager(pidFilePath, port);
2418
+ let daemonStarted = false;
2419
+ if (daemon.isRunning()) {
2420
+ console.log(t("cli.codex.alreadyRunning"));
2421
+ daemonStarted = true;
2422
+ } else {
2423
+ console.log(t("cli.codex.starting"));
2424
+ daemonStarted = await daemon.start();
2425
+ if (!daemonStarted) {
2426
+ console.error(t("cli.codex.startFailed"));
2427
+ process.exit(1);
2428
+ }
2429
+ console.log(t("cli.codex.started"));
2430
+ }
2431
+ if (!config.agent.codex?.enabled) {
2432
+ console.error(t("cli.codex.notEnabled"));
2433
+ console.error(t("cli.codex.enableHint"));
2434
+ process.exit(1);
2435
+ }
2436
+ const workDir = options.directory || process.cwd();
2437
+ const codexCommand = config.agent.codex?.command?.[0] || "codex";
2438
+ const codexArgs = config.agent.codex?.command?.slice(1) || ["app-server", "--listen", "stdio://"];
2439
+ console.log(t("cli.codex.startingIn", { dir: workDir, version: version || "unknown" }));
2440
+ console.log(t("cli.codex.pressCtrlC") + "\n");
2441
+ const codexProcess = spawn4(codexCommand, codexArgs, {
2442
+ cwd: workDir,
2443
+ stdio: "inherit",
2444
+ shell: true
2445
+ });
2446
+ const logger = createAgentLogger("codex");
2447
+ logger.info({ command: codexCommand, args: codexArgs, cwd: workDir }, "Codex CLI spawned");
2448
+ codexProcess.on("exit", (code) => {
2449
+ logger.info({ code }, "Codex CLI exited");
2450
+ console.log(t("cli.codex.exited", { code }));
2451
+ console.log(t("cli.codex.daemonRunning"));
2452
+ process.exit(code || 0);
2453
+ });
2454
+ codexProcess.on("error", (err) => {
2455
+ logger.error({ error: err.message }, "Codex CLI spawn failed");
2456
+ });
2457
+ process.on("SIGINT", () => {
2458
+ console.log(t("cli.codex.detaching"));
2459
+ codexProcess.kill("SIGINT");
2460
+ });
2461
+ process.on("SIGTERM", () => {
2462
+ console.log(t("cli.codex.detaching"));
2463
+ codexProcess.kill("SIGTERM");
2464
+ });
2465
+ });
2466
+ }
2467
+
1924
2468
  // src/cli/agent/index.ts
1925
2469
  function registerAgentCommands(program2) {
1926
2470
  registerClaudeCommand(program2);
2471
+ registerCodexCommand(program2);
1927
2472
  }
1928
2473
 
1929
2474
  // src/cli/index.ts