@commonlyai/cli 0.1.34 → 0.1.36
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/package.json +1 -1
- package/src/commands/agent.js +14 -1
- package/src/commands/daemon.js +50 -1
- package/src/lib/daemon-service.js +135 -0
- package/src/lib/enforcement.js +14 -0
package/package.json
CHANGED
package/src/commands/agent.js
CHANGED
|
@@ -50,6 +50,7 @@ import {
|
|
|
50
50
|
createClaimHandicap,
|
|
51
51
|
createClaimKeeper,
|
|
52
52
|
deliverChatReply,
|
|
53
|
+
frameDecisionForkRule,
|
|
53
54
|
peerHoldsFrame,
|
|
54
55
|
resolveCascadeSettings,
|
|
55
56
|
} from '../lib/enforcement.js';
|
|
@@ -694,6 +695,18 @@ const extractPrompt = (event) => {
|
|
|
694
695
|
'Only post a concise pod update if a human needs it; otherwise return NO_REPLY.',
|
|
695
696
|
].join('\n');
|
|
696
697
|
}
|
|
698
|
+
if (event.type === 'decision.ruled') {
|
|
699
|
+
if (!p.decisionId || !p.pick || !p.ruledAt) return null;
|
|
700
|
+
const ruler = p.ruledBy?.username || 'A human';
|
|
701
|
+
return [
|
|
702
|
+
'[Decision ruled]',
|
|
703
|
+
`${ruler} chose: ${String(p.pick)}`,
|
|
704
|
+
`Decision: ${String(p.decisionId)}`,
|
|
705
|
+
`Ruled at: ${String(p.ruledAt)}`,
|
|
706
|
+
'',
|
|
707
|
+
'Continue with this ruling. Post a concise update only if it materially helps the pod; otherwise return NO_REPLY.',
|
|
708
|
+
].join('\n');
|
|
709
|
+
}
|
|
697
710
|
return null;
|
|
698
711
|
};
|
|
699
712
|
|
|
@@ -1050,7 +1063,7 @@ export const performRun = ({
|
|
|
1050
1063
|
const memoryLongTerm = await readLongTerm(client, { onError });
|
|
1051
1064
|
|
|
1052
1065
|
log(`[${event.type}] spawning ${adapter.name}`);
|
|
1053
|
-
const result = await adapter.spawn(prompt, {
|
|
1066
|
+
const result = await adapter.spawn(frameDecisionForkRule(prompt), {
|
|
1054
1067
|
sessionId,
|
|
1055
1068
|
cwd: agentCwd,
|
|
1056
1069
|
env: process.env,
|
package/src/commands/daemon.js
CHANGED
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
|
|
9
9
|
import { hostname, homedir } from 'os';
|
|
10
10
|
import { spawn } from 'child_process';
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
existsSync, mkdirSync, openSync, rmSync, writeFileSync,
|
|
13
|
+
} from 'fs';
|
|
12
14
|
import { join } from 'path';
|
|
13
15
|
import { createClient } from '../lib/api.js';
|
|
14
16
|
import { getToken, resolveInstanceUrl } from '../lib/config.js';
|
|
@@ -20,6 +22,10 @@ import {
|
|
|
20
22
|
} from '../lib/daemon-supervisor.js';
|
|
21
23
|
import { loadAgentToken, saveAgentToken } from './agent.js';
|
|
22
24
|
import { getAdapter } from '../lib/adapters/index.js';
|
|
25
|
+
import {
|
|
26
|
+
installDaemonService,
|
|
27
|
+
uninstallDaemonService,
|
|
28
|
+
} from '../lib/daemon-service.js';
|
|
23
29
|
|
|
24
30
|
const requireDaemonRecord = () => {
|
|
25
31
|
const record = loadDaemonRecord();
|
|
@@ -171,6 +177,49 @@ Examples:
|
|
|
171
177
|
}
|
|
172
178
|
});
|
|
173
179
|
|
|
180
|
+
// ── install / uninstall (ADR-026 D1) ──────────────────────────────────────
|
|
181
|
+
const serviceDeps = () => ({
|
|
182
|
+
writeFile: (file, content) => writeFileSync(file, content, 'utf8'),
|
|
183
|
+
mkdirp: (dir) => { if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); },
|
|
184
|
+
existsFile: (file) => existsSync(file),
|
|
185
|
+
removeFile: (file) => rmSync(file),
|
|
186
|
+
execCmd: (argv) => new Promise((resolvePromise, rejectPromise) => {
|
|
187
|
+
const child = spawn(argv[0], argv.slice(1), { stdio: 'ignore' });
|
|
188
|
+
child.on('error', rejectPromise);
|
|
189
|
+
child.on('exit', (code) => (code === 0
|
|
190
|
+
? resolvePromise()
|
|
191
|
+
: rejectPromise(new Error(`${argv.join(' ')} exited ${code}`))));
|
|
192
|
+
}),
|
|
193
|
+
log: (line) => console.log(line),
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
daemon
|
|
197
|
+
.command('install')
|
|
198
|
+
.description('Register the daemon as a login service (launchd/systemd) so it survives reboots')
|
|
199
|
+
.action(async () => {
|
|
200
|
+
try {
|
|
201
|
+
// A service without a credential just crash-loops at boot.
|
|
202
|
+
requireDaemonRecord();
|
|
203
|
+
await installDaemonService(serviceDeps());
|
|
204
|
+
console.log('The daemon now starts at login and is kept alive. Uninstall with: commonly daemon uninstall');
|
|
205
|
+
} catch (error) {
|
|
206
|
+
console.error(`Daemon install failed: ${error.message}`);
|
|
207
|
+
process.exitCode = 1;
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
daemon
|
|
212
|
+
.command('uninstall')
|
|
213
|
+
.description('Remove the daemon login service (agents stop being supervised on this machine)')
|
|
214
|
+
.action(async () => {
|
|
215
|
+
try {
|
|
216
|
+
await uninstallDaemonService(serviceDeps());
|
|
217
|
+
} catch (error) {
|
|
218
|
+
console.error(`Daemon uninstall failed: ${error.message}`);
|
|
219
|
+
process.exitCode = 1;
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
174
223
|
// ── run (ADR-026 Phase 2, slice 2) ────────────────────────────────────────
|
|
175
224
|
daemon
|
|
176
225
|
.command('run')
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-026 D1: install-once service registration for the resident daemon.
|
|
3
|
+
*
|
|
4
|
+
* macOS gets a launchd LaunchAgent (KeepAlive — launchd itself restarts a
|
|
5
|
+
* dead daemon), Linux a systemd user unit (Restart=always). Both run the
|
|
6
|
+
* exact interpreter + CLI entry that executed `daemon install`, resolved at
|
|
7
|
+
* install time — a PATH lookup at boot would race version managers and
|
|
8
|
+
* silently run a different install than the one the user tested.
|
|
9
|
+
*
|
|
10
|
+
* Pure generators + injected side effects, same discipline as the
|
|
11
|
+
* supervisor: the unit CONTENT is unit-testable without touching launchctl.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { homedir } from 'os';
|
|
15
|
+
import { join, dirname, resolve } from 'path';
|
|
16
|
+
|
|
17
|
+
export const LAUNCHD_LABEL = 'me.commonly.daemon';
|
|
18
|
+
export const SYSTEMD_UNIT = 'commonly-daemon.service';
|
|
19
|
+
|
|
20
|
+
export const daemonLogPath = (home = homedir()) => join(home, '.commonly', 'logs', 'daemon', 'daemon.log');
|
|
21
|
+
|
|
22
|
+
export const servicePaths = (platform = process.platform, home = homedir()) => (
|
|
23
|
+
platform === 'darwin'
|
|
24
|
+
? { kind: 'launchd', file: join(home, 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`) }
|
|
25
|
+
: { kind: 'systemd', file: join(home, '.config', 'systemd', 'user', SYSTEMD_UNIT) }
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
const xmlEscape = (value) => String(value)
|
|
29
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
30
|
+
|
|
31
|
+
// PATH matters for the CHILDREN: the daemon spawns `commonly agent run`,
|
|
32
|
+
// which spawns the user's claude/codex CLI by name. launchd's default PATH
|
|
33
|
+
// has no /opt/homebrew/bin, so without this the daemon comes up and every
|
|
34
|
+
// seat dies at adapter detection.
|
|
35
|
+
const childPath = (nodePath) => [
|
|
36
|
+
dirname(nodePath), '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin',
|
|
37
|
+
].filter((entry, index, all) => all.indexOf(entry) === index).join(':');
|
|
38
|
+
|
|
39
|
+
export const launchdPlist = ({ nodePath, cliPath, home = homedir() }) => `<?xml version="1.0" encoding="UTF-8"?>
|
|
40
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
41
|
+
<plist version="1.0">
|
|
42
|
+
<dict>
|
|
43
|
+
\t<key>Label</key>
|
|
44
|
+
\t<string>${LAUNCHD_LABEL}</string>
|
|
45
|
+
\t<key>EnvironmentVariables</key>
|
|
46
|
+
\t<dict>
|
|
47
|
+
\t\t<key>PATH</key>
|
|
48
|
+
\t\t<string>${xmlEscape(childPath(nodePath))}</string>
|
|
49
|
+
\t\t<key>HOME</key>
|
|
50
|
+
\t\t<string>${xmlEscape(home)}</string>
|
|
51
|
+
\t</dict>
|
|
52
|
+
\t<key>ProgramArguments</key>
|
|
53
|
+
\t<array>
|
|
54
|
+
\t\t<string>${xmlEscape(nodePath)}</string>
|
|
55
|
+
\t\t<string>${xmlEscape(cliPath)}</string>
|
|
56
|
+
\t\t<string>daemon</string>
|
|
57
|
+
\t\t<string>run</string>
|
|
58
|
+
\t</array>
|
|
59
|
+
\t<key>RunAtLoad</key>
|
|
60
|
+
\t<true/>
|
|
61
|
+
\t<key>KeepAlive</key>
|
|
62
|
+
\t<true/>
|
|
63
|
+
\t<key>StandardOutPath</key>
|
|
64
|
+
\t<string>${xmlEscape(daemonLogPath(home))}</string>
|
|
65
|
+
\t<key>StandardErrorPath</key>
|
|
66
|
+
\t<string>${xmlEscape(daemonLogPath(home))}</string>
|
|
67
|
+
</dict>
|
|
68
|
+
</plist>
|
|
69
|
+
`;
|
|
70
|
+
|
|
71
|
+
export const systemdUnit = ({ nodePath, cliPath }) => `[Unit]
|
|
72
|
+
Description=Commonly local agent daemon (ADR-026)
|
|
73
|
+
After=network-online.target
|
|
74
|
+
|
|
75
|
+
[Service]
|
|
76
|
+
ExecStart=${nodePath} ${cliPath} daemon run
|
|
77
|
+
Restart=always
|
|
78
|
+
RestartSec=5
|
|
79
|
+
Environment=PATH=${childPath(nodePath)}
|
|
80
|
+
|
|
81
|
+
[Install]
|
|
82
|
+
WantedBy=default.target
|
|
83
|
+
`;
|
|
84
|
+
|
|
85
|
+
export const installDaemonService = async ({
|
|
86
|
+
platform = process.platform,
|
|
87
|
+
home = homedir(),
|
|
88
|
+
nodePath = process.execPath,
|
|
89
|
+
cliPath = resolve(process.argv[1]),
|
|
90
|
+
writeFile,
|
|
91
|
+
mkdirp,
|
|
92
|
+
execCmd, // async (argv: string[]) => void — throws on failure
|
|
93
|
+
log = () => {},
|
|
94
|
+
}) => {
|
|
95
|
+
const target = servicePaths(platform, home);
|
|
96
|
+
mkdirp(dirname(target.file));
|
|
97
|
+
mkdirp(dirname(daemonLogPath(home)));
|
|
98
|
+
|
|
99
|
+
if (target.kind === 'launchd') {
|
|
100
|
+
writeFile(target.file, launchdPlist({ nodePath, cliPath, home }));
|
|
101
|
+
// Reload cleanly if a previous version is loaded; the unload of an
|
|
102
|
+
// unknown label fails by design and is ignored.
|
|
103
|
+
await execCmd(['launchctl', 'unload', target.file]).catch(() => {});
|
|
104
|
+
await execCmd(['launchctl', 'load', '-w', target.file]);
|
|
105
|
+
} else {
|
|
106
|
+
writeFile(target.file, systemdUnit({ nodePath, cliPath }));
|
|
107
|
+
await execCmd(['systemctl', '--user', 'daemon-reload']);
|
|
108
|
+
await execCmd(['systemctl', '--user', 'enable', '--now', SYSTEMD_UNIT]);
|
|
109
|
+
}
|
|
110
|
+
log(`Installed ${target.kind} service (${target.file}). Logs: ${daemonLogPath(home)}`);
|
|
111
|
+
return target;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export const uninstallDaemonService = async ({
|
|
115
|
+
platform = process.platform,
|
|
116
|
+
home = homedir(),
|
|
117
|
+
existsFile,
|
|
118
|
+
removeFile,
|
|
119
|
+
execCmd,
|
|
120
|
+
log = () => {},
|
|
121
|
+
}) => {
|
|
122
|
+
const target = servicePaths(platform, home);
|
|
123
|
+
if (!existsFile(target.file)) {
|
|
124
|
+
log('No installed daemon service found.');
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
if (target.kind === 'launchd') {
|
|
128
|
+
await execCmd(['launchctl', 'unload', '-w', target.file]).catch(() => {});
|
|
129
|
+
} else {
|
|
130
|
+
await execCmd(['systemctl', '--user', 'disable', '--now', SYSTEMD_UNIT]).catch(() => {});
|
|
131
|
+
}
|
|
132
|
+
removeFile(target.file);
|
|
133
|
+
log(`Removed ${target.kind} service (${target.file}).`);
|
|
134
|
+
return target;
|
|
135
|
+
};
|
package/src/lib/enforcement.js
CHANGED
|
@@ -28,6 +28,20 @@ export const CLAIMABLE_EVENT_TYPES = new Set([
|
|
|
28
28
|
'dm.message',
|
|
29
29
|
]);
|
|
30
30
|
|
|
31
|
+
// A decision card is the durable path for a real human choice. This lives in
|
|
32
|
+
// wrapper enforcement, rather than only in the MCP tool description, because
|
|
33
|
+
// a task-shaped prompt can otherwise make an agent phrase the same fork as a
|
|
34
|
+
// prose @ask. The rule is framing, not a classifier: the agent still judges
|
|
35
|
+
// whether the work genuinely cannot continue without a human ruling.
|
|
36
|
+
export const DECISION_FORK_FRAME = [
|
|
37
|
+
'[Decision forks]',
|
|
38
|
+
'If you are blocked on a genuine fork that needs a human choice, call commonly_request_decision; do not post a prose @ask.',
|
|
39
|
+
'Use it only when you cannot safely continue before a ruling. Give 2–4 concrete options and mark one recommendation.',
|
|
40
|
+
'Use ordinary pod messages for status, factual questions, and coordination that do not require a human choice.',
|
|
41
|
+
].join('\n');
|
|
42
|
+
|
|
43
|
+
export const frameDecisionForkRule = (prompt) => `${DECISION_FORK_FRAME}\n\n${prompt}`;
|
|
44
|
+
|
|
31
45
|
// ── trigger classification ──────────────────────────────────────────────────
|
|
32
46
|
|
|
33
47
|
/**
|