carmoji 0.3.2 → 0.3.3
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 +144 -20
- 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
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
//
|
|
7
7
|
// Commands:
|
|
8
8
|
// pair <code> discover the phone on this Wi-Fi and pair with it
|
|
9
|
+
// (pairing is additive — events fan out to every device)
|
|
10
|
+
// unpair [device] forget a paired device by name or ip[:port], or `all`
|
|
9
11
|
// install <tool> install hooks for a tool (or `all`); see `tools`
|
|
10
12
|
// uninstall <tool> remove CarMoji hooks from a tool's config
|
|
11
13
|
// tools list supported tools and where their hooks live
|
|
@@ -52,6 +54,30 @@ function saveConfig(config) {
|
|
|
52
54
|
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n');
|
|
53
55
|
}
|
|
54
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Paired devices from a config of either vintage: the original single
|
|
59
|
+
* top-level {host, port, code}, or the current {devices: [{name, host,
|
|
60
|
+
* port, code}, …]}. Saving always writes the multi-device shape.
|
|
61
|
+
*/
|
|
62
|
+
function normalizeDevices(config) {
|
|
63
|
+
if (!config) return [];
|
|
64
|
+
const list = Array.isArray(config.devices) ? config.devices
|
|
65
|
+
: config.host ? [config] : [];
|
|
66
|
+
return list.filter((d) => d && d.host && d.port && d.code);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function loadDevices() {
|
|
70
|
+
return normalizeDevices(loadConfig());
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function saveDevices(devices) {
|
|
74
|
+
saveConfig({ devices });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function deviceLabel(device) {
|
|
78
|
+
return `${device.name ?? device.host} — ${device.host}:${device.port}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
55
81
|
function readStdin() {
|
|
56
82
|
try {
|
|
57
83
|
return readFileSync(0, 'utf8');
|
|
@@ -135,6 +161,14 @@ async function discoverViaMulticastJS() {
|
|
|
135
161
|
});
|
|
136
162
|
}
|
|
137
163
|
|
|
164
|
+
async function askTTY(question) {
|
|
165
|
+
const { createInterface } = await import('node:readline/promises');
|
|
166
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
167
|
+
const answer = await rl.question(question);
|
|
168
|
+
rl.close();
|
|
169
|
+
return answer.trim();
|
|
170
|
+
}
|
|
171
|
+
|
|
138
172
|
/** Let the user pick between several discovered phones. */
|
|
139
173
|
async function chooseDevice(devices) {
|
|
140
174
|
console.log('Multiple CarMoji devices found:');
|
|
@@ -143,10 +177,7 @@ async function chooseDevice(devices) {
|
|
|
143
177
|
console.error('\nRe-run with the address of the one you want: carmoji pair <code> <ip[:port]>');
|
|
144
178
|
process.exit(1);
|
|
145
179
|
}
|
|
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();
|
|
180
|
+
const answer = await askTTY(`Pair with which one? [1-${devices.length}] `);
|
|
150
181
|
const device = devices[Number(answer) - 1];
|
|
151
182
|
if (!device) {
|
|
152
183
|
console.error('Invalid choice.');
|
|
@@ -220,6 +251,30 @@ async function requestApproval(config, message, timeoutMs = 55_000) {
|
|
|
220
251
|
});
|
|
221
252
|
}
|
|
222
253
|
|
|
254
|
+
/** Fan one event out to every paired phone in parallel; one slow or
|
|
255
|
+
* unreachable device can't delay the others past SEND_TIMEOUT_MS. */
|
|
256
|
+
function sendAll(devices, message) {
|
|
257
|
+
return Promise.all(devices.map((device) => send(device, message)));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Show the approval card on every paired phone; the first tap anywhere
|
|
262
|
+
* wins. The other phones' cards drop when this process exits and their
|
|
263
|
+
* sockets close. Resolves null when nobody answers in time.
|
|
264
|
+
*/
|
|
265
|
+
function requestApprovalAll(devices, message, timeoutMs) {
|
|
266
|
+
if (devices.length === 0) return Promise.resolve(null);
|
|
267
|
+
return new Promise((resolve) => {
|
|
268
|
+
let pending = devices.length;
|
|
269
|
+
for (const device of devices) {
|
|
270
|
+
requestApproval(device, message, timeoutMs).then((decision) => {
|
|
271
|
+
pending -= 1;
|
|
272
|
+
if (decision || pending === 0) resolve(decision);
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
223
278
|
function summarizeToolInput(payload) {
|
|
224
279
|
const input = payload.tool_input || {};
|
|
225
280
|
const text = input.command || input.file_path || input.description
|
|
@@ -946,27 +1001,92 @@ async function main() {
|
|
|
946
1001
|
}
|
|
947
1002
|
target = devices.length === 1 ? devices[0] : await chooseDevice(devices);
|
|
948
1003
|
}
|
|
949
|
-
const
|
|
950
|
-
|
|
1004
|
+
const device = { name: target.name, host: target.host,
|
|
1005
|
+
port: target.port, code: arg };
|
|
1006
|
+
const result = await send(device, { source: 'bridge', event: 'turn_done' });
|
|
951
1007
|
if (result !== 'ok') {
|
|
952
1008
|
console.error(result === 'bad-code'
|
|
953
1009
|
? `"${target.name}" (${target.host}:${target.port}) rejected the pairing code — check that device’s Code Buddy sheet. (Wrong phone? Pass its address: carmoji pair <code> <ip[:port]>)`
|
|
954
1010
|
: `Found "${target.name}" at ${target.host}:${target.port} but couldn’t talk to it (${result}).`);
|
|
955
1011
|
process.exit(1);
|
|
956
1012
|
}
|
|
957
|
-
|
|
1013
|
+
// Pairing the same device again just refreshes its code; other
|
|
1014
|
+
// devices stay paired unless the user says otherwise here.
|
|
1015
|
+
const others = loadDevices().filter(
|
|
1016
|
+
(d) => !(d.host === device.host && Number(d.port) === Number(device.port)));
|
|
1017
|
+
let kept = others;
|
|
1018
|
+
if (others.length > 0) {
|
|
1019
|
+
console.log('Already paired with:');
|
|
1020
|
+
others.forEach((d) => console.log(` ${deviceLabel(d)}`));
|
|
1021
|
+
if (process.stdin.isTTY) {
|
|
1022
|
+
const answer = await askTTY(
|
|
1023
|
+
'Keep them too? Events go to every paired device. [Y/n] ');
|
|
1024
|
+
if (/^n/i.test(answer)) {
|
|
1025
|
+
kept = [];
|
|
1026
|
+
console.log('Unpaired them — events go only to the new device.');
|
|
1027
|
+
}
|
|
1028
|
+
} else {
|
|
1029
|
+
console.log('Keeping them — events fan out to every paired device.'
|
|
1030
|
+
+ ' (`carmoji unpair` removes one.)');
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
saveDevices([...kept, device]);
|
|
958
1034
|
console.log(`Paired with "${target.name}" at ${target.host}:${target.port} — the pet should be celebrating!`);
|
|
959
1035
|
break;
|
|
960
1036
|
}
|
|
961
1037
|
|
|
1038
|
+
case 'unpair': {
|
|
1039
|
+
const devices = loadDevices();
|
|
1040
|
+
if (devices.length === 0) {
|
|
1041
|
+
console.log('Not paired with any device.');
|
|
1042
|
+
break;
|
|
1043
|
+
}
|
|
1044
|
+
let goners;
|
|
1045
|
+
if (arg === 'all') {
|
|
1046
|
+
goners = devices;
|
|
1047
|
+
} else if (arg) {
|
|
1048
|
+
goners = devices.filter((d) => d.name === arg || d.host === arg
|
|
1049
|
+
|| `${d.host}:${d.port}` === arg);
|
|
1050
|
+
if (goners.length === 0) {
|
|
1051
|
+
console.error(`No paired device matches "${arg}". Paired:`);
|
|
1052
|
+
devices.forEach((d) => console.error(` ${deviceLabel(d)}`));
|
|
1053
|
+
process.exit(1);
|
|
1054
|
+
}
|
|
1055
|
+
} else if (devices.length === 1) {
|
|
1056
|
+
goners = devices;
|
|
1057
|
+
} else if (process.stdin.isTTY) {
|
|
1058
|
+
console.log('Paired devices:');
|
|
1059
|
+
devices.forEach((d, i) => console.log(` ${i + 1}. ${deviceLabel(d)}`));
|
|
1060
|
+
const answer = await askTTY(
|
|
1061
|
+
`Unpair which one? [1-${devices.length}, or "all"] `);
|
|
1062
|
+
if (answer.toLowerCase() === 'all') {
|
|
1063
|
+
goners = devices;
|
|
1064
|
+
} else {
|
|
1065
|
+
const device = devices[Number(answer) - 1];
|
|
1066
|
+
if (!device) {
|
|
1067
|
+
console.error('Invalid choice.');
|
|
1068
|
+
process.exit(1);
|
|
1069
|
+
}
|
|
1070
|
+
goners = [device];
|
|
1071
|
+
}
|
|
1072
|
+
} else {
|
|
1073
|
+
console.error('Several devices are paired — name one: carmoji unpair <name|ip[:port]|all>');
|
|
1074
|
+
devices.forEach((d) => console.error(` ${deviceLabel(d)}`));
|
|
1075
|
+
process.exit(1);
|
|
1076
|
+
}
|
|
1077
|
+
saveDevices(devices.filter((d) => !goners.includes(d)));
|
|
1078
|
+
goners.forEach((d) => console.log(`Unpaired ${deviceLabel(d)}.`));
|
|
1079
|
+
break;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
962
1082
|
case 'hook': {
|
|
963
1083
|
// Runs inside the coding agent on every hook — must be silent, quick,
|
|
964
1084
|
// and always exit 0 so it can never disturb the session.
|
|
965
1085
|
// Flags: --source <tool> (default claude), --event <name> for tools
|
|
966
1086
|
// whose stdin lacks the event name, --gate for phone approvals.
|
|
967
1087
|
try {
|
|
968
|
-
const
|
|
969
|
-
if (
|
|
1088
|
+
const devices = loadDevices();
|
|
1089
|
+
if (devices.length === 0) break;
|
|
970
1090
|
const flags = [arg, ...rest].filter(Boolean);
|
|
971
1091
|
const flagValue = (name) => {
|
|
972
1092
|
const index = flags.indexOf(name);
|
|
@@ -980,7 +1100,7 @@ async function main() {
|
|
|
980
1100
|
// the phone shows Allow/Deny; the printed JSON settles the
|
|
981
1101
|
// permission. Timeout falls through to the normal terminal prompt.
|
|
982
1102
|
if (flags.includes('--gate') && payload.hook_event_name === 'PreToolUse') {
|
|
983
|
-
const decision = await
|
|
1103
|
+
const decision = await requestApprovalAll(devices, {
|
|
984
1104
|
source,
|
|
985
1105
|
session: payload.session_id,
|
|
986
1106
|
event: 'approval_request',
|
|
@@ -1032,7 +1152,7 @@ async function main() {
|
|
|
1032
1152
|
const usage = claudeUsage(recompute);
|
|
1033
1153
|
if (usage) message.usage = usage;
|
|
1034
1154
|
}
|
|
1035
|
-
await
|
|
1155
|
+
await sendAll(devices, message);
|
|
1036
1156
|
} catch {
|
|
1037
1157
|
// Never surface errors into the coding session.
|
|
1038
1158
|
}
|
|
@@ -1041,14 +1161,14 @@ async function main() {
|
|
|
1041
1161
|
|
|
1042
1162
|
case 'codex-notify': {
|
|
1043
1163
|
try {
|
|
1044
|
-
const
|
|
1045
|
-
if (
|
|
1164
|
+
const devices = loadDevices();
|
|
1165
|
+
if (devices.length === 0) break;
|
|
1046
1166
|
const payload = JSON.parse(process.argv[process.argv.length - 1] || '{}');
|
|
1047
1167
|
if (payload.type === 'agent-turn-complete') {
|
|
1048
1168
|
const session = payload['conversation-id'] || payload.conversation_id;
|
|
1049
1169
|
const message = { source: 'codex', session, event: 'turn_done' };
|
|
1050
1170
|
if (payload.cwd) message.project = basename(payload.cwd);
|
|
1051
|
-
await
|
|
1171
|
+
await sendAll(devices, message);
|
|
1052
1172
|
}
|
|
1053
1173
|
} catch {
|
|
1054
1174
|
// Same rule as `hook`: stay silent.
|
|
@@ -1062,8 +1182,8 @@ async function main() {
|
|
|
1062
1182
|
console.error(`Usage: carmoji test <${Object.keys(TEST_EVENTS).join('|')}> [session] [tokens]`);
|
|
1063
1183
|
process.exit(1);
|
|
1064
1184
|
}
|
|
1065
|
-
const
|
|
1066
|
-
if (
|
|
1185
|
+
const devices = loadDevices();
|
|
1186
|
+
if (devices.length === 0) {
|
|
1067
1187
|
console.error('Not paired yet — run: carmoji pair <code>');
|
|
1068
1188
|
process.exit(1);
|
|
1069
1189
|
}
|
|
@@ -1073,9 +1193,12 @@ async function main() {
|
|
|
1073
1193
|
if (project) message.project = project;
|
|
1074
1194
|
const usage = claudeUsage(true);
|
|
1075
1195
|
if (usage) message.usage = usage;
|
|
1076
|
-
const
|
|
1077
|
-
|
|
1078
|
-
|
|
1196
|
+
const results = await sendAll(devices, message);
|
|
1197
|
+
results.forEach((result, index) => {
|
|
1198
|
+
console.log(` ${deviceLabel(devices[index])}: ${
|
|
1199
|
+
result === 'ok' ? 'sent!' : `failed (${result})`}`);
|
|
1200
|
+
});
|
|
1201
|
+
process.exit(results.every((result) => result === 'ok') ? 0 : 1);
|
|
1079
1202
|
break;
|
|
1080
1203
|
}
|
|
1081
1204
|
|
|
@@ -1167,13 +1290,14 @@ async function main() {
|
|
|
1167
1290
|
break;
|
|
1168
1291
|
|
|
1169
1292
|
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');
|
|
1293
|
+
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
1294
|
}
|
|
1172
1295
|
}
|
|
1173
1296
|
|
|
1174
1297
|
export {
|
|
1175
1298
|
applyTranscriptTokenRecord,
|
|
1176
1299
|
eventFromHook,
|
|
1300
|
+
normalizeDevices,
|
|
1177
1301
|
stripLegacyCarMojiNotifyLine,
|
|
1178
1302
|
};
|
|
1179
1303
|
|