carmoji 0.3.2 → 0.3.4
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/README.md +6 -0
- package/carmoji.js +174 -31
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -38,6 +38,12 @@ approval.
|
|
|
38
38
|
through the system's `dns-sd`, which handles Macs full of VPN tunnels
|
|
39
39
|
and virtual interfaces; other platforms use a JS mDNS fallback.
|
|
40
40
|
|
|
41
|
+
Pairing is additive: pair again with a second phone and every event
|
|
42
|
+
fans out to all paired devices (approval requests go to all of them
|
|
43
|
+
too — the first tap wins). When devices are already paired, `pair`
|
|
44
|
+
asks whether to keep them. `npx carmoji unpair` forgets a device —
|
|
45
|
+
by name or address, or `unpair all` for a clean slate.
|
|
46
|
+
|
|
41
47
|
## Claude Code
|
|
42
48
|
|
|
43
49
|
Coding in *this* repo works out of the box — the hooks ship in
|
package/carmoji.js
CHANGED
|
@@ -5,7 +5,10 @@
|
|
|
5
5
|
// address + pairing code in ~/.config/carmoji/config.json.
|
|
6
6
|
//
|
|
7
7
|
// Commands:
|
|
8
|
-
// pair <code> discover
|
|
8
|
+
// pair <code> discover phones on this Wi-Fi and pair with the one
|
|
9
|
+
// whose Code Buddy sheet shows <code>
|
|
10
|
+
// (pairing is additive — events fan out to every device)
|
|
11
|
+
// unpair [device] forget a paired device by name or ip[:port], or `all`
|
|
9
12
|
// install <tool> install hooks for a tool (or `all`); see `tools`
|
|
10
13
|
// uninstall <tool> remove CarMoji hooks from a tool's config
|
|
11
14
|
// tools list supported tools and where their hooks live
|
|
@@ -52,6 +55,30 @@ function saveConfig(config) {
|
|
|
52
55
|
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n');
|
|
53
56
|
}
|
|
54
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Paired devices from a config of either vintage: the original single
|
|
60
|
+
* top-level {host, port, code}, or the current {devices: [{name, host,
|
|
61
|
+
* port, code}, …]}. Saving always writes the multi-device shape.
|
|
62
|
+
*/
|
|
63
|
+
function normalizeDevices(config) {
|
|
64
|
+
if (!config) return [];
|
|
65
|
+
const list = Array.isArray(config.devices) ? config.devices
|
|
66
|
+
: config.host ? [config] : [];
|
|
67
|
+
return list.filter((d) => d && d.host && d.port && d.code);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function loadDevices() {
|
|
71
|
+
return normalizeDevices(loadConfig());
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function saveDevices(devices) {
|
|
75
|
+
saveConfig({ devices });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function deviceLabel(device) {
|
|
79
|
+
return `${device.name ?? device.host} — ${device.host}:${device.port}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
55
82
|
function readStdin() {
|
|
56
83
|
try {
|
|
57
84
|
return readFileSync(0, 'utf8');
|
|
@@ -135,18 +162,23 @@ async function discoverViaMulticastJS() {
|
|
|
135
162
|
});
|
|
136
163
|
}
|
|
137
164
|
|
|
165
|
+
async function askTTY(question) {
|
|
166
|
+
const { createInterface } = await import('node:readline/promises');
|
|
167
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
168
|
+
const answer = await rl.question(question);
|
|
169
|
+
rl.close();
|
|
170
|
+
return answer.trim();
|
|
171
|
+
}
|
|
172
|
+
|
|
138
173
|
/** Let the user pick between several discovered phones. */
|
|
139
|
-
async function chooseDevice(devices) {
|
|
140
|
-
console.log(
|
|
174
|
+
async function chooseDevice(devices, heading = 'Multiple CarMoji devices found:') {
|
|
175
|
+
console.log(heading);
|
|
141
176
|
devices.forEach((d, i) => console.log(` ${i + 1}. ${d.name} — ${d.host}:${d.port}`));
|
|
142
177
|
if (!process.stdin.isTTY) {
|
|
143
178
|
console.error('\nRe-run with the address of the one you want: carmoji pair <code> <ip[:port]>');
|
|
144
179
|
process.exit(1);
|
|
145
180
|
}
|
|
146
|
-
const
|
|
147
|
-
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
148
|
-
const answer = await rl.question(`Pair with which one? [1-${devices.length}] `);
|
|
149
|
-
rl.close();
|
|
181
|
+
const answer = await askTTY(`Pair with which one? [1-${devices.length}] `);
|
|
150
182
|
const device = devices[Number(answer) - 1];
|
|
151
183
|
if (!device) {
|
|
152
184
|
console.error('Invalid choice.');
|
|
@@ -220,6 +252,30 @@ async function requestApproval(config, message, timeoutMs = 55_000) {
|
|
|
220
252
|
});
|
|
221
253
|
}
|
|
222
254
|
|
|
255
|
+
/** Fan one event out to every paired phone in parallel; one slow or
|
|
256
|
+
* unreachable device can't delay the others past SEND_TIMEOUT_MS. */
|
|
257
|
+
function sendAll(devices, message) {
|
|
258
|
+
return Promise.all(devices.map((device) => send(device, message)));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Show the approval card on every paired phone; the first tap anywhere
|
|
263
|
+
* wins. The other phones' cards drop when this process exits and their
|
|
264
|
+
* sockets close. Resolves null when nobody answers in time.
|
|
265
|
+
*/
|
|
266
|
+
function requestApprovalAll(devices, message, timeoutMs) {
|
|
267
|
+
if (devices.length === 0) return Promise.resolve(null);
|
|
268
|
+
return new Promise((resolve) => {
|
|
269
|
+
let pending = devices.length;
|
|
270
|
+
for (const device of devices) {
|
|
271
|
+
requestApproval(device, message, timeoutMs).then((decision) => {
|
|
272
|
+
pending -= 1;
|
|
273
|
+
if (decision || pending === 0) resolve(decision);
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
223
279
|
function summarizeToolInput(payload) {
|
|
224
280
|
const input = payload.tool_input || {};
|
|
225
281
|
const text = input.command || input.file_path || input.description
|
|
@@ -932,10 +988,18 @@ async function main() {
|
|
|
932
988
|
let target;
|
|
933
989
|
const manual = rest[0];
|
|
934
990
|
if (manual) {
|
|
935
|
-
// Manual addressing
|
|
936
|
-
//
|
|
991
|
+
// Manual addressing (the app's Code Buddy sheet shows the address)
|
|
992
|
+
// for networks where mDNS is blocked.
|
|
937
993
|
const [host, port] = manual.split(':');
|
|
938
994
|
target = { host, port: Number(port) || 8737, name: host };
|
|
995
|
+
const result = await send({ ...target, code: arg },
|
|
996
|
+
{ source: 'bridge', event: 'turn_done' });
|
|
997
|
+
if (result !== 'ok') {
|
|
998
|
+
console.error(result === 'bad-code'
|
|
999
|
+
? `"${target.name}" (${target.host}:${target.port}) rejected the pairing code — check that device’s Code Buddy sheet.`
|
|
1000
|
+
: `Found "${target.name}" at ${target.host}:${target.port} but couldn’t talk to it (${result}).`);
|
|
1001
|
+
process.exit(1);
|
|
1002
|
+
}
|
|
939
1003
|
} else {
|
|
940
1004
|
console.log('Looking for CarMoji on this network…');
|
|
941
1005
|
const devices = await discoverAll();
|
|
@@ -944,29 +1008,104 @@ async function main() {
|
|
|
944
1008
|
console.error('You can also pair by address: carmoji pair <code> <ip[:port]>');
|
|
945
1009
|
process.exit(1);
|
|
946
1010
|
}
|
|
947
|
-
|
|
1011
|
+
// The pairing code identifies the phone: probe every discovered
|
|
1012
|
+
// device with it and pair with whichever accepts — no picking from
|
|
1013
|
+
// a list when several phones share the network.
|
|
1014
|
+
const results = await Promise.all(devices.map((d) =>
|
|
1015
|
+
send({ ...d, code: arg }, { source: 'bridge', event: 'turn_done' })));
|
|
1016
|
+
const accepted = devices.filter((_, i) => results[i] === 'ok');
|
|
1017
|
+
if (accepted.length === 0) {
|
|
1018
|
+
console.error(results.includes('bad-code')
|
|
1019
|
+
? `No device on this network accepted code ${arg} — check the Code Buddy sheet on the phone you want:`
|
|
1020
|
+
: 'Found CarMoji but couldn’t talk to it:');
|
|
1021
|
+
devices.forEach((d, i) =>
|
|
1022
|
+
console.error(` ${deviceLabel(d)}: ${results[i]}`));
|
|
1023
|
+
process.exit(1);
|
|
1024
|
+
}
|
|
1025
|
+
// Two phones drew the same 4-digit code — let the user break the
|
|
1026
|
+
// tie instead of guessing.
|
|
1027
|
+
target = accepted.length === 1 ? accepted[0]
|
|
1028
|
+
: await chooseDevice(accepted, 'Several devices accepted this pairing code:');
|
|
948
1029
|
}
|
|
949
|
-
const
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
1030
|
+
const device = { name: target.name, host: target.host,
|
|
1031
|
+
port: target.port, code: arg };
|
|
1032
|
+
// Pairing the same device again just refreshes its code; other
|
|
1033
|
+
// devices stay paired unless the user says otherwise here.
|
|
1034
|
+
const others = loadDevices().filter(
|
|
1035
|
+
(d) => !(d.host === device.host && Number(d.port) === Number(device.port)));
|
|
1036
|
+
let kept = others;
|
|
1037
|
+
if (others.length > 0) {
|
|
1038
|
+
console.log('Already paired with:');
|
|
1039
|
+
others.forEach((d) => console.log(` ${deviceLabel(d)}`));
|
|
1040
|
+
if (process.stdin.isTTY) {
|
|
1041
|
+
const answer = await askTTY(
|
|
1042
|
+
'Keep them too? Events go to every paired device. [Y/n] ');
|
|
1043
|
+
if (/^n/i.test(answer)) {
|
|
1044
|
+
kept = [];
|
|
1045
|
+
console.log('Unpaired them — events go only to the new device.');
|
|
1046
|
+
}
|
|
1047
|
+
} else {
|
|
1048
|
+
console.log('Keeping them — events fan out to every paired device.'
|
|
1049
|
+
+ ' (`carmoji unpair` removes one.)');
|
|
1050
|
+
}
|
|
956
1051
|
}
|
|
957
|
-
|
|
1052
|
+
saveDevices([...kept, device]);
|
|
958
1053
|
console.log(`Paired with "${target.name}" at ${target.host}:${target.port} — the pet should be celebrating!`);
|
|
959
1054
|
break;
|
|
960
1055
|
}
|
|
961
1056
|
|
|
1057
|
+
case 'unpair': {
|
|
1058
|
+
const devices = loadDevices();
|
|
1059
|
+
if (devices.length === 0) {
|
|
1060
|
+
console.log('Not paired with any device.');
|
|
1061
|
+
break;
|
|
1062
|
+
}
|
|
1063
|
+
let goners;
|
|
1064
|
+
if (arg === 'all') {
|
|
1065
|
+
goners = devices;
|
|
1066
|
+
} else if (arg) {
|
|
1067
|
+
goners = devices.filter((d) => d.name === arg || d.host === arg
|
|
1068
|
+
|| `${d.host}:${d.port}` === arg);
|
|
1069
|
+
if (goners.length === 0) {
|
|
1070
|
+
console.error(`No paired device matches "${arg}". Paired:`);
|
|
1071
|
+
devices.forEach((d) => console.error(` ${deviceLabel(d)}`));
|
|
1072
|
+
process.exit(1);
|
|
1073
|
+
}
|
|
1074
|
+
} else if (devices.length === 1) {
|
|
1075
|
+
goners = devices;
|
|
1076
|
+
} else if (process.stdin.isTTY) {
|
|
1077
|
+
console.log('Paired devices:');
|
|
1078
|
+
devices.forEach((d, i) => console.log(` ${i + 1}. ${deviceLabel(d)}`));
|
|
1079
|
+
const answer = await askTTY(
|
|
1080
|
+
`Unpair which one? [1-${devices.length}, or "all"] `);
|
|
1081
|
+
if (answer.toLowerCase() === 'all') {
|
|
1082
|
+
goners = devices;
|
|
1083
|
+
} else {
|
|
1084
|
+
const device = devices[Number(answer) - 1];
|
|
1085
|
+
if (!device) {
|
|
1086
|
+
console.error('Invalid choice.');
|
|
1087
|
+
process.exit(1);
|
|
1088
|
+
}
|
|
1089
|
+
goners = [device];
|
|
1090
|
+
}
|
|
1091
|
+
} else {
|
|
1092
|
+
console.error('Several devices are paired — name one: carmoji unpair <name|ip[:port]|all>');
|
|
1093
|
+
devices.forEach((d) => console.error(` ${deviceLabel(d)}`));
|
|
1094
|
+
process.exit(1);
|
|
1095
|
+
}
|
|
1096
|
+
saveDevices(devices.filter((d) => !goners.includes(d)));
|
|
1097
|
+
goners.forEach((d) => console.log(`Unpaired ${deviceLabel(d)}.`));
|
|
1098
|
+
break;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
962
1101
|
case 'hook': {
|
|
963
1102
|
// Runs inside the coding agent on every hook — must be silent, quick,
|
|
964
1103
|
// and always exit 0 so it can never disturb the session.
|
|
965
1104
|
// Flags: --source <tool> (default claude), --event <name> for tools
|
|
966
1105
|
// whose stdin lacks the event name, --gate for phone approvals.
|
|
967
1106
|
try {
|
|
968
|
-
const
|
|
969
|
-
if (
|
|
1107
|
+
const devices = loadDevices();
|
|
1108
|
+
if (devices.length === 0) break;
|
|
970
1109
|
const flags = [arg, ...rest].filter(Boolean);
|
|
971
1110
|
const flagValue = (name) => {
|
|
972
1111
|
const index = flags.indexOf(name);
|
|
@@ -980,7 +1119,7 @@ async function main() {
|
|
|
980
1119
|
// the phone shows Allow/Deny; the printed JSON settles the
|
|
981
1120
|
// permission. Timeout falls through to the normal terminal prompt.
|
|
982
1121
|
if (flags.includes('--gate') && payload.hook_event_name === 'PreToolUse') {
|
|
983
|
-
const decision = await
|
|
1122
|
+
const decision = await requestApprovalAll(devices, {
|
|
984
1123
|
source,
|
|
985
1124
|
session: payload.session_id,
|
|
986
1125
|
event: 'approval_request',
|
|
@@ -1032,7 +1171,7 @@ async function main() {
|
|
|
1032
1171
|
const usage = claudeUsage(recompute);
|
|
1033
1172
|
if (usage) message.usage = usage;
|
|
1034
1173
|
}
|
|
1035
|
-
await
|
|
1174
|
+
await sendAll(devices, message);
|
|
1036
1175
|
} catch {
|
|
1037
1176
|
// Never surface errors into the coding session.
|
|
1038
1177
|
}
|
|
@@ -1041,14 +1180,14 @@ async function main() {
|
|
|
1041
1180
|
|
|
1042
1181
|
case 'codex-notify': {
|
|
1043
1182
|
try {
|
|
1044
|
-
const
|
|
1045
|
-
if (
|
|
1183
|
+
const devices = loadDevices();
|
|
1184
|
+
if (devices.length === 0) break;
|
|
1046
1185
|
const payload = JSON.parse(process.argv[process.argv.length - 1] || '{}');
|
|
1047
1186
|
if (payload.type === 'agent-turn-complete') {
|
|
1048
1187
|
const session = payload['conversation-id'] || payload.conversation_id;
|
|
1049
1188
|
const message = { source: 'codex', session, event: 'turn_done' };
|
|
1050
1189
|
if (payload.cwd) message.project = basename(payload.cwd);
|
|
1051
|
-
await
|
|
1190
|
+
await sendAll(devices, message);
|
|
1052
1191
|
}
|
|
1053
1192
|
} catch {
|
|
1054
1193
|
// Same rule as `hook`: stay silent.
|
|
@@ -1062,8 +1201,8 @@ async function main() {
|
|
|
1062
1201
|
console.error(`Usage: carmoji test <${Object.keys(TEST_EVENTS).join('|')}> [session] [tokens]`);
|
|
1063
1202
|
process.exit(1);
|
|
1064
1203
|
}
|
|
1065
|
-
const
|
|
1066
|
-
if (
|
|
1204
|
+
const devices = loadDevices();
|
|
1205
|
+
if (devices.length === 0) {
|
|
1067
1206
|
console.error('Not paired yet — run: carmoji pair <code>');
|
|
1068
1207
|
process.exit(1);
|
|
1069
1208
|
}
|
|
@@ -1073,9 +1212,12 @@ async function main() {
|
|
|
1073
1212
|
if (project) message.project = project;
|
|
1074
1213
|
const usage = claudeUsage(true);
|
|
1075
1214
|
if (usage) message.usage = usage;
|
|
1076
|
-
const
|
|
1077
|
-
|
|
1078
|
-
|
|
1215
|
+
const results = await sendAll(devices, message);
|
|
1216
|
+
results.forEach((result, index) => {
|
|
1217
|
+
console.log(` ${deviceLabel(devices[index])}: ${
|
|
1218
|
+
result === 'ok' ? 'sent!' : `failed (${result})`}`);
|
|
1219
|
+
});
|
|
1220
|
+
process.exit(results.every((result) => result === 'ok') ? 0 : 1);
|
|
1079
1221
|
break;
|
|
1080
1222
|
}
|
|
1081
1223
|
|
|
@@ -1167,13 +1309,14 @@ async function main() {
|
|
|
1167
1309
|
break;
|
|
1168
1310
|
|
|
1169
1311
|
default:
|
|
1170
|
-
console.log('carmoji bridge — usage: pair <code> [ip[:port]] | discover | install <tool>|all | uninstall <tool> | tools | test <event> | hook [--source <tool>] [--event <name>] [--gate] | codex-notify | claude-config | codex-config | gate-config');
|
|
1312
|
+
console.log('carmoji bridge — usage: pair <code> [ip[:port]] | unpair [<name|ip[:port]>|all] | discover | install <tool>|all | uninstall <tool> | tools | test <event> | hook [--source <tool>] [--event <name>] [--gate] | codex-notify | claude-config | codex-config | gate-config');
|
|
1171
1313
|
}
|
|
1172
1314
|
}
|
|
1173
1315
|
|
|
1174
1316
|
export {
|
|
1175
1317
|
applyTranscriptTokenRecord,
|
|
1176
1318
|
eventFromHook,
|
|
1319
|
+
normalizeDevices,
|
|
1177
1320
|
stripLegacyCarMojiNotifyLine,
|
|
1178
1321
|
};
|
|
1179
1322
|
|