handsoff 0.1.2-beta.0 → 0.1.2-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,20 +2,9 @@
2
2
  # Copy to ~/.handsoff/config.toml and fill in your credentials
3
3
 
4
4
  [general]
5
- default_agent = "claude"
6
5
  log_level = "info"
7
6
  hook_server_port = 9876
8
7
 
9
- [channel.telegram]
10
- enabled = true
11
- bot_token = "YOUR_BOT_TOKEN"
12
- allowed_users = [123456789]
13
-
14
- [channel.feishu]
15
- enabled = false
16
- app_id = ""
17
- app_secret = ""
18
-
19
8
  [agent.claude]
20
9
  adapter = "hooks"
21
10
  binary = "claude"
@@ -23,22 +12,26 @@ work_dir = ""
23
12
  model = ""
24
13
  permission_mode = ""
25
14
 
26
- # Agent-specific permission settings
27
- [agent.claude.permission]
28
- # Low-risk tools that won't trigger permission prompts
29
- lowRiskTools = ["Read", "Glob", "Grep", "List", "WebSearch", "codesearch"]
15
+ [agent.codex]
16
+ enabled = false
17
+ command = ["codex", "app-server", "--listen", "stdio://"]
18
+ work_dir = ""
19
+ model = ""
20
+ approval_policy = ""
30
21
 
31
- [channels.app]
22
+ [channel.app]
32
23
  enabled = false
33
24
  channel_id = "app:desktop-001"
34
25
  auth_token = "change-me"
35
26
  notify_types = ["*"]
36
27
  heartbeat_interval_ms = 30000
37
28
 
38
- [agent.codex]
29
+ [channel.telegram]
30
+ enabled = true
31
+ bot_token = "YOUR_BOT_TOKEN"
32
+ allowed_users = [123456789]
33
+
34
+ [channel.feishu]
39
35
  enabled = false
40
- command = ["codex", "app-server", "--listen", "stdio://"]
41
- work_dir = ""
42
- model = ""
43
- approval_policy = ""
44
- sandbox_mode = ""
36
+ app_id = ""
37
+ app_secret = ""
package/dist/cli/index.js CHANGED
@@ -8,7 +8,13 @@ import { Command } from "commander";
8
8
 
9
9
  // src/shared/settings-merge.ts
10
10
  function isHandsoffHook(hook) {
11
- return hook.type === "http" && hook.url.includes("/hook/");
11
+ if (hook.type === "http" && hook.url) {
12
+ return hook.url.includes("/hook/");
13
+ }
14
+ if (hook.type === "command" && hook.command) {
15
+ return hook.command.includes("/hook/") && hook.command.includes("curl");
16
+ }
17
+ return false;
12
18
  }
13
19
  function isHandsoffEntry(entry) {
14
20
  return entry.hooks.some((hook) => isHandsoffHook(hook));
@@ -35,10 +41,15 @@ function mergeHooks(existing, newHooks) {
35
41
  const existingEntries = cleaned.hooks?.[eventName] || [];
36
42
  const newEntries = newHooks[eventName] || [];
37
43
  const existingUrls = new Set(
38
- existingEntries.flatMap((e) => e.hooks.map((h) => h.url))
44
+ existingEntries.flatMap((e) => e.hooks.map((h) => h.url).filter(Boolean))
45
+ );
46
+ const existingCommands = new Set(
47
+ existingEntries.flatMap((e) => e.hooks.map((h) => h.command).filter(Boolean))
39
48
  );
40
49
  const uniqueNewEntries = newEntries.filter(
41
- (entry) => !entry.hooks.some((h) => existingUrls.has(h.url))
50
+ (entry) => !entry.hooks.some(
51
+ (h) => h.url && existingUrls.has(h.url) || h.command && existingCommands.has(h.command)
52
+ )
42
53
  );
43
54
  const merged = [...uniqueNewEntries, ...existingEntries];
44
55
  if (merged.length > 0) {
@@ -124,7 +135,6 @@ import dotenv from "dotenv";
124
135
  dotenv.config();
125
136
  var DEFAULT_CONFIG = {
126
137
  general: {
127
- default_agent: "claude",
128
138
  log_level: "info",
129
139
  hook_server_port: 9876
130
140
  },
@@ -148,6 +158,11 @@ var DEFAULT_CONFIG = {
148
158
  timeout_ms: 3e5,
149
159
  // 5 minutes
150
160
  default_on_timeout: "deny"
161
+ },
162
+ app: {
163
+ enabled: false,
164
+ channel_id: "",
165
+ auth_token: ""
151
166
  }
152
167
  },
153
168
  agent: {
@@ -156,21 +171,16 @@ var DEFAULT_CONFIG = {
156
171
  binary: "claude",
157
172
  work_dir: "",
158
173
  model: "",
159
- permission_mode: "",
160
- permission: {
161
- lowRiskTools: ["Read", "Glob", "Grep", "List", "WebSearch", "codesearch"]
162
- }
174
+ permission_mode: ""
163
175
  },
164
176
  codex: {
165
177
  enabled: false,
166
178
  command: ["codex", "app-server", "--listen", "stdio://"],
167
179
  work_dir: "",
168
180
  model: "",
169
- approval_policy: "",
170
- sandbox_mode: ""
181
+ approval_policy: ""
171
182
  }
172
183
  },
173
- channels: {},
174
184
  bindings: {}
175
185
  };
176
186
  function getConfigPath() {
@@ -218,16 +228,21 @@ function mergeWithDefaults(raw) {
218
228
  10
219
229
  ),
220
230
  default_on_timeout: process.env.HANDSOFF_PERMISSION_DEFAULT || raw.channel?.permission?.default_on_timeout || DEFAULT_CONFIG.channel.permission.default_on_timeout
231
+ },
232
+ app: {
233
+ ...DEFAULT_CONFIG.channel.app,
234
+ ...raw.channel?.app || {},
235
+ enabled: raw.channel?.app?.enabled === true,
236
+ channel_id: String(raw.channel?.app?.channel_id || ""),
237
+ auth_token: String(raw.channel?.app?.auth_token || ""),
238
+ heartbeat_interval_ms: raw.channel?.app?.heartbeat_interval_ms ? parseInt(String(raw.channel?.app?.heartbeat_interval_ms), 10) : void 0,
239
+ notify_types: Array.isArray(raw.channel?.app?.notify_types) ? raw.channel.app.notify_types.map(String) : void 0
221
240
  }
222
241
  },
223
242
  agent: {
224
243
  claude: {
225
244
  ...DEFAULT_CONFIG.agent.claude,
226
- ...raw.agent?.claude || {},
227
- permission: {
228
- ...DEFAULT_CONFIG.agent.claude.permission,
229
- ...raw.agent?.claude?.permission || {}
230
- }
245
+ ...raw.agent?.claude || {}
231
246
  },
232
247
  codex: {
233
248
  ...DEFAULT_CONFIG.agent.codex,
@@ -235,21 +250,9 @@ function mergeWithDefaults(raw) {
235
250
  command: raw.agent?.codex?.command ? raw.agent.codex.command : DEFAULT_CONFIG.agent.codex.command,
236
251
  work_dir: raw.agent?.codex?.work_dir || DEFAULT_CONFIG.agent.codex.work_dir,
237
252
  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
253
+ approval_policy: raw.agent?.codex?.approval_policy || DEFAULT_CONFIG.agent.codex.approval_policy
240
254
  }
241
255
  },
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
256
  bindings: {
254
257
  ...DEFAULT_CONFIG.bindings,
255
258
  ...raw.bindings || {}
@@ -297,7 +300,10 @@ var en_default = {
297
300
  injecting: "Injecting hooks...",
298
301
  injected: "Hooks injected",
299
302
  failed: "Failed: {{error}}",
300
- remoteModeSkipHooks: "Remote mode does not require hooks; skipping injection."
303
+ remoteModeSkipHooks: "Remote mode does not require hooks; skipping injection.",
304
+ cleaningHooks: "Cleaning up hooks from previous mode...",
305
+ hooksCleaned: "Hooks cleaned",
306
+ cleanupWarning: "Hook cleanup warning: {{error}}"
301
307
  },
302
308
  channel: {
303
309
  configuring: "Configuring {{channel}}",
@@ -516,6 +522,22 @@ var en_default = {
516
522
  unauthorized: "You are not authorized to use this bot.",
517
523
  received: "Message received.",
518
524
  processingError: "Error processing message."
525
+ },
526
+ eventTitles: {
527
+ sessionStart: "\u{1F680} Session Started",
528
+ sessionEnd: "\u{1F44B} Session Ended",
529
+ turnFinished: "\u2705 Task Completed",
530
+ turnThinking: "\u{1F4AD} Thinking...",
531
+ toolPost: "\u{1F527} Tool Call",
532
+ toolExecuted: "\u2705 Tool Executed",
533
+ toolFailure: "\u274C Tool Failed",
534
+ permissionRequest: "\u{1F510} Permission Request",
535
+ permissionResponse: "\u{1F4CB} Permission Response",
536
+ questionRequest: "\u2753 Question",
537
+ questionResponse: "\u{1F4CB} Question Response",
538
+ agentMessage: "\u{1F916} Agent Message",
539
+ error: "\u26A0\uFE0F Error",
540
+ fallback: "\u{1F4CB} Notification"
519
541
  }
520
542
  };
521
543
 
@@ -783,6 +805,16 @@ function extractHandsoffHookInfo(entry) {
783
805
  }
784
806
  return { isHandsoff: false };
785
807
  }
808
+ function detectPermissionMode() {
809
+ const settingsPath = join5(homedir5(), ".claude", "settings.json");
810
+ if (!existsSync4(settingsPath)) return null;
811
+ try {
812
+ const settings = JSON.parse(readFileSync4(settingsPath, "utf-8"));
813
+ return settings.permissionMode ?? null;
814
+ } catch {
815
+ return null;
816
+ }
817
+ }
786
818
 
787
819
  // src/cli/wizard/detectors/codex.ts
788
820
  import { execSync as execSync2 } from "child_process";
@@ -864,6 +896,18 @@ var prompts = {
864
896
  { name: "Remote mode (managed two-way)", value: "remote" }
865
897
  ]
866
898
  }),
899
+ permissionModeSelect: (defaultValue) => select({
900
+ message: "Permission Mode",
901
+ choices: [
902
+ { name: "acceptEdits \u2014 Auto-approve safe edits (Recommended)", value: "acceptEdits" },
903
+ { name: "default \u2014 Ask via TTY or hook", value: "default" },
904
+ { name: "bypassPermissions \u2014 Approve all, no prompts", value: "bypassPermissions" },
905
+ { name: "dontAsk \u2014 Deny all tool execution", value: "dontAsk" },
906
+ { name: "plan \u2014 Plan only, no tool execution", value: "plan" },
907
+ { name: "auto \u2014 Auto-handle based on risk level", value: "auto" }
908
+ ],
909
+ default: defaultValue ?? "acceptEdits"
910
+ }),
867
911
  cliHookSelect: (selected) => checkbox({
868
912
  message: t("wizard.cli.hookCheckbox"),
869
913
  choices: [
@@ -900,10 +944,10 @@ var prompts = {
900
944
  notifyTypes: (selected) => checkbox({
901
945
  message: t("wizard.notify.checkbox"),
902
946
  choices: [
903
- { name: "permission_request", value: "permission_request", checked: selected?.includes("permission_request") },
904
- { name: "question_request", value: "question_request", checked: selected?.includes("question_request") },
905
- { name: "finished", value: "finished", checked: selected?.includes("finished") },
906
- { name: "session_start", value: "session_start", checked: selected?.includes("session_start") },
947
+ { name: "permission:request", value: "permission:request", checked: selected?.includes("permission:request") },
948
+ { name: "question:request", value: "question:request", checked: selected?.includes("question:request") },
949
+ { name: "turn:finished", value: "turn:finished", checked: selected?.includes("turn:finished") },
950
+ { name: "session:start", value: "session:start", checked: selected?.includes("session:start") },
907
951
  { name: "error", value: "error", checked: selected?.includes("error") }
908
952
  ]
909
953
  }),
@@ -933,8 +977,8 @@ var prompts = {
933
977
  pairContinue: () => select({
934
978
  message: "Add another pair?",
935
979
  choices: [
936
- { name: "Add another pair", value: "continue" },
937
- { name: "Finish setup", value: "finish" }
980
+ { name: "Finish setup", value: "finish" },
981
+ { name: "Add another pair", value: "continue" }
938
982
  ]
939
983
  }),
940
984
  agentSelect: (agents) => select({
@@ -1006,6 +1050,13 @@ ${STEP} \u2014 Configure Agent
1006
1050
  return { action: "next", next: "cli-menu" };
1007
1051
  }
1008
1052
  } else {
1053
+ const spinner = ora(t("wizard.cli.cleaningHooks")).start();
1054
+ try {
1055
+ cleanupClaudeHooks(detection.settingsPath);
1056
+ spinner.succeed(t("wizard.cli.hooksCleaned"));
1057
+ } catch (err) {
1058
+ spinner.warn(t("wizard.cli.cleanupWarning", { error: String(err) }));
1059
+ }
1009
1060
  console.log(pc.yellow(`! ${t("wizard.cli.remoteModeSkipHooks")}`));
1010
1061
  try {
1011
1062
  execSync3("claude --version", { stdio: "ignore" });
@@ -1023,6 +1074,9 @@ ${STEP} \u2014 Configure Agent
1023
1074
  }
1024
1075
  };
1025
1076
  state.validationResults.set("claude", { ok: true });
1077
+ if (mode === "remote") {
1078
+ return { action: "next", next: "permission-mode" };
1079
+ }
1026
1080
  return { action: "next", next: "channel-select" };
1027
1081
  }
1028
1082
  if (cliName === "codex") {
@@ -1087,8 +1141,7 @@ function parseCommandInput(input2) {
1087
1141
  function injectClaudeHooks(settingsPath, events) {
1088
1142
  const port = loadConfig().general.hook_server_port;
1089
1143
  const token = getOrCreateHookToken();
1090
- const baseUrl = `http://localhost:${port}/hook/${token}`;
1091
- const hooksConfig = generateHooksConfig2(baseUrl, events);
1144
+ const hooksConfig = generateHooksConfig2(port, token, events);
1092
1145
  let currentSettings = {};
1093
1146
  if (existsSync5(settingsPath)) {
1094
1147
  try {
@@ -1103,17 +1156,40 @@ function injectClaudeHooks(settingsPath, events) {
1103
1156
  mkdirSync4(dirname3(settingsPath), { recursive: true });
1104
1157
  writeFileSync4(settingsPath, JSON.stringify(mergedSettings, null, 2));
1105
1158
  }
1106
- function generateHooksConfig2(baseUrl, events) {
1107
- const createEntry = (event) => ({
1108
- matcher: "",
1109
- hooks: [{ type: "http", url: `${baseUrl}/${event}` }]
1110
- });
1159
+ var COMMAND_ONLY_HOOKS = ["SessionStart", "Setup"];
1160
+ function generateHooksConfig2(port, token, events) {
1161
+ const unifiedUrl = `http://localhost:${port}/hook/${token}/event`;
1162
+ const curlCommand = `curl -s -X POST -H 'Content-Type: application/json' -d @- ${unifiedUrl}`;
1163
+ const createEntry = (event) => {
1164
+ const isCommandOnly = COMMAND_ONLY_HOOKS.includes(event);
1165
+ return {
1166
+ matcher: "",
1167
+ hooks: [{
1168
+ type: isCommandOnly ? "command" : "http",
1169
+ ...isCommandOnly ? { command: curlCommand } : { url: unifiedUrl }
1170
+ }]
1171
+ };
1172
+ };
1111
1173
  const config = {};
1112
1174
  for (const event of events) {
1113
1175
  config[event] = [createEntry(event)];
1114
1176
  }
1115
1177
  return config;
1116
1178
  }
1179
+ function cleanupClaudeHooks(settingsPath) {
1180
+ if (!existsSync5(settingsPath)) return;
1181
+ let currentSettings = {};
1182
+ try {
1183
+ currentSettings = JSON.parse(readFileSync5(settingsPath, "utf-8"));
1184
+ } catch {
1185
+ }
1186
+ const cleanedSettings = removeHandsoffHooks(currentSettings);
1187
+ if (!cleanedSettings.hooks || Object.keys(cleanedSettings.hooks).length === 0) {
1188
+ delete cleanedSettings.hooks;
1189
+ }
1190
+ mkdirSync4(dirname3(settingsPath), { recursive: true });
1191
+ writeFileSync4(settingsPath, JSON.stringify(cleanedSettings, null, 2));
1192
+ }
1117
1193
 
1118
1194
  // src/cli/wizard/detectors/channel.ts
1119
1195
  async function detectChannel(name) {
@@ -1343,7 +1419,7 @@ ${STEP3} \u2014 Select Agent
1343
1419
  if (result.next === "cli-menu") {
1344
1420
  return { action: "next", next: "agent-select" };
1345
1421
  }
1346
- return { action: "next", next: "channel-select" };
1422
+ return { action: "next", next: result.next };
1347
1423
  }
1348
1424
 
1349
1425
  // src/shared/channelInstance.ts
@@ -1373,7 +1449,6 @@ ${STEP4} \u2014 Select Channel
1373
1449
  const loggerInstanceId = "logger:default";
1374
1450
  const loggerBoundAgent = Object.entries(currentBindings).find(([, v]) => v === loggerInstanceId)?.[0];
1375
1451
  const loggerStatus = loggerBoundAgent ? pc6.green(`[bound to ${loggerBoundAgent}]`) : loggerEnabled ? pc6.green("[configured]") : pc6.gray("[not configured]");
1376
- channels.push({ name: "logger", value: "logger", status: loggerStatus });
1377
1452
  if (mergedTelegram?.enabled && mergedTelegram.bot_token) {
1378
1453
  const instanceId = getChannelInstanceId("telegram", mergedTelegram.bot_token);
1379
1454
  const boundAgent = Object.entries(currentBindings).find(([, v]) => v === instanceId)?.[0];
@@ -1390,6 +1465,7 @@ ${STEP4} \u2014 Select Channel
1390
1465
  } else {
1391
1466
  channels.push({ name: "feishu", value: "feishu", status: pc6.gray("[not configured]") });
1392
1467
  }
1468
+ channels.push({ name: "logger", value: "logger", status: loggerStatus });
1393
1469
  const choice = await prompts.channelSelect(channels);
1394
1470
  state.currentChannelName = choice;
1395
1471
  const result = await stepChannelConfig(state);
@@ -1719,6 +1795,33 @@ ${t("wizard.final.done")}
1719
1795
  return { action: "next", next: "done" };
1720
1796
  }
1721
1797
 
1798
+ // src/cli/wizard/steps/permission-mode.ts
1799
+ import pc10 from "picocolors";
1800
+ var STEP7 = "STEP 3";
1801
+ async function stepPermissionMode(state) {
1802
+ console.log(pc10.bold(`
1803
+ ${STEP7} \u2014 Permission Mode
1804
+ `));
1805
+ console.log(pc10.gray(`${"\u2500".repeat(50)}
1806
+ `));
1807
+ const config = loadConfig();
1808
+ const current = state.pendingChanges?.agent?.claude;
1809
+ const saved = current?.permission_mode ?? config.agent.claude?.permission_mode ?? null;
1810
+ const detected = detectPermissionMode();
1811
+ const initial = saved ?? detected ?? "acceptEdits";
1812
+ console.log(pc10.gray("Controls how Claude Code handles permission requests.\n"));
1813
+ if (detected && !saved) {
1814
+ console.log(pc10.gray(`Detected from ~/.claude/settings.json: ${detected}
1815
+ `));
1816
+ }
1817
+ const selected = await prompts.permissionModeSelect(initial);
1818
+ state.pendingChanges ??= {};
1819
+ state.pendingChanges.agent ??= {};
1820
+ state.pendingChanges.agent.claude ??= {};
1821
+ state.pendingChanges.agent.claude.permission_mode = selected;
1822
+ return { action: "next", next: "channel-select" };
1823
+ }
1824
+
1722
1825
  // src/cli/wizard/engine.ts
1723
1826
  var WizardEngine = class {
1724
1827
  state;
@@ -1779,6 +1882,8 @@ var WizardEngine = class {
1779
1882
  return await stepPairContinue(this.state);
1780
1883
  case "cli":
1781
1884
  return await stepCli(this.state);
1885
+ case "permission-mode":
1886
+ return await stepPermissionMode(this.state);
1782
1887
  case "cli-menu":
1783
1888
  return await stepCliMenu(this.state);
1784
1889
  case "channel-config":
@@ -2255,22 +2360,29 @@ function writeSettings2(settingsPath, settings) {
2255
2360
  }
2256
2361
  function generateHooksConfig3(port, token) {
2257
2362
  const unifiedUrl = `http://localhost:${port}/hook/${token}/event`;
2258
- const createHookEntry = () => ({
2363
+ const createHttpHookEntry = () => ({
2259
2364
  matcher: "",
2260
2365
  hooks: [{ type: "http", url: unifiedUrl }]
2261
2366
  });
2367
+ const curlCommand = `curl -s -X POST -H 'Content-Type: application/json' -d @- ${unifiedUrl}`;
2368
+ const createCommandHookEntry = () => ({
2369
+ matcher: "",
2370
+ hooks: [{ type: "command", command: curlCommand }]
2371
+ });
2262
2372
  return {
2263
- Stop: [createHookEntry()],
2264
- PostToolUse: [createHookEntry()],
2265
- PostToolUseFailure: [createHookEntry()],
2266
- Notification: [createHookEntry()],
2267
- PreToolUse: [createHookEntry()],
2268
- SubagentStart: [createHookEntry()],
2269
- SubagentStop: [createHookEntry()],
2270
- SessionStart: [createHookEntry()],
2271
- SessionEnd: [createHookEntry()],
2272
- UserPromptSubmit: [createHookEntry()],
2273
- PermissionRequest: [createHookEntry()]
2373
+ // HTTP type hooks
2374
+ Stop: [createHttpHookEntry()],
2375
+ PostToolUse: [createHttpHookEntry()],
2376
+ PostToolUseFailure: [createHttpHookEntry()],
2377
+ Notification: [createHttpHookEntry()],
2378
+ PreToolUse: [createHttpHookEntry()],
2379
+ SubagentStart: [createHttpHookEntry()],
2380
+ SubagentStop: [createHttpHookEntry()],
2381
+ SessionEnd: [createHttpHookEntry()],
2382
+ UserPromptSubmit: [createHttpHookEntry()],
2383
+ PermissionRequest: [createHttpHookEntry()],
2384
+ // Command type hooks (HTTP not supported by Claude)
2385
+ SessionStart: [createCommandHookEntry()]
2274
2386
  };
2275
2387
  }
2276
2388
  function registerClaudeCommand(program2) {