@koalaauto/agentchannel 3.12.0 → 3.13.1

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/src/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { execFileSync, execSync, spawn } from 'node:child_process';
3
3
  import { randomBytes } from 'node:crypto';
4
- import { chmodSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
4
+ import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
5
5
  import { basename, delimiter, dirname, isAbsolute, join, relative, resolve } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { HELP, parseCli, UsageError } from './args.js';
@@ -1606,6 +1606,10 @@ async function runCommand(p, io) {
1606
1606
  }
1607
1607
  if (v['install-service'])
1608
1608
  return installService(p, io, { agentName: me.name, dir, repos: repoFlags, home });
1609
+ // started by a service written by an early release (node on the npx cache, which no restart ever refreshes): make it `npx -y <spec>`
1610
+ const moved = await moveOldService(io, me.name, home);
1611
+ if (moved)
1612
+ return UPDATE_EXIT_CODE;
1609
1613
  const runtime = str(v.runtime) ?? (me.runtime === 'codex' ? 'codex' : 'claude');
1610
1614
  const { makeAdapter } = await import('./adapters/index.js');
1611
1615
  const adapter = makeAdapter(runtime, { model: str(v.model) });
@@ -1768,6 +1772,9 @@ async function installService(p, io, o) {
1768
1772
  }, env.ACH_SERVICE_DIR || undefined, env);
1769
1773
  writeServiceFile(f);
1770
1774
  const load = !env.ACH_SERVICE_DIR;
1775
+ // an agent that was renamed has an older service under its former name: two runners on one key. They go.
1776
+ const { retireSiblings } = await import('./servicefix.js');
1777
+ const retired = retireSiblings({ platform, dir: env.ACH_SERVICE_DIR || undefined, env, ownPath: f.path, ownHome: o.home, run: load });
1771
1778
  let failed = '';
1772
1779
  if (load) {
1773
1780
  for (const c of f.start) {
@@ -1787,12 +1794,13 @@ async function installService(p, io, o) {
1787
1794
  const linger = platform === 'linux' && load && !failed ? lingers(env) : true;
1788
1795
  const logs = platform === 'linux' ? `journalctl --user -u ${basename(f.path)} -f` : join(o.home, 'logs', 'runner.log');
1789
1796
  if (v.json) {
1790
- io.out(JSON.stringify({ path: f.path, started: load && !failed, ...(failed ? { error: failed } : {}), start: f.start, stop: f.stop }, null, 2));
1797
+ io.out(JSON.stringify({ path: f.path, started: load && !failed, ...(failed ? { error: failed } : {}), start: f.start, stop: f.stop, retired }, null, 2));
1791
1798
  return failed ? 1 : 0;
1792
1799
  }
1793
1800
  io.out([
1794
1801
  !load ? `wrote ${f.path} (not loaded: ACH_SERVICE_DIR is set). Start it with:` : failed ? `wrote ${f.path}, but starting it failed: ${failed}\nStart it with:` : `installed and started ${f.path} (also starts at login)`,
1795
1802
  ...(!load || failed ? f.start.map((x) => ` ${x}`) : []),
1803
+ ...retired.map((r) => `stopped and removed the older service of this agent (${r.agentName}): ${r.path}`),
1796
1804
  `Stop it with: ${f.stop.join(' && ')}`,
1797
1805
  `Runner output: ${logs}`,
1798
1806
  ...(linger ? [] : ['Note: it stops when you log out of this machine. To keep it running: sudo loginctl enable-linger "$USER"']),
@@ -1809,6 +1817,38 @@ function lingers(env) {
1809
1817
  return true;
1810
1818
  }
1811
1819
  }
1820
+ /** The runner is the service's own process and the service is in the old form: rewrite it, have the supervisor reload it, and stop (the reload starts the new one). */
1821
+ async function moveOldService(io, agentName, home) {
1822
+ const env = io.env;
1823
+ const platform = (env.ACH_SERVICE_PLATFORM || process.platform);
1824
+ if (platform !== 'darwin' && platform !== 'linux')
1825
+ return false;
1826
+ const { migrateOldService, reloadDetached, startedByService } = await import('./servicefix.js');
1827
+ if (!env.ACH_SERVICE_DIR && !startedByService(agentName, platform, env))
1828
+ return false;
1829
+ const { npmSpec, whichAll, isBundledBin } = await import('./invite.js');
1830
+ // the new service must be able to start: without an npx to point at, the old one (stale, but running) stays as it is
1831
+ const beside = join(dirname(process.execPath), 'npx');
1832
+ const npx = whichAll('npx', env).find((x) => !isBundledBin(x)) ?? (existsSync(beside) ? beside : '');
1833
+ if (!npx) {
1834
+ io.err('[ach run] this service starts a fixed copy of the CLI from the npx cache, but there is no npx to start it with: leaving it. Run `ach run --install-service` after installing npm.');
1835
+ return false;
1836
+ }
1837
+ let m;
1838
+ try {
1839
+ m = migrateOldService({ agentName, platform, dir: env.ACH_SERVICE_DIR || undefined, env, home, npx, spec: npmSpec() });
1840
+ }
1841
+ catch (e) {
1842
+ io.err(`[ach run] could not rewrite the service file: ${e?.message ?? e}`);
1843
+ return false;
1844
+ }
1845
+ if (!m)
1846
+ return false;
1847
+ io.err(`[ach run] this service started a fixed copy of the CLI from the npx cache, which no restart updates: rewrote ${m.path} to start \`npx -y ${npmSpec()}\` and reloading it`);
1848
+ if (!env.ACH_SERVICE_DIR)
1849
+ reloadDetached(m.start, env);
1850
+ return true;
1851
+ }
1812
1852
  function isEntry() {
1813
1853
  try {
1814
1854
  return !!process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
@@ -219,11 +219,11 @@ export function sandboxFix(st, host) {
219
219
  }
220
220
  if (appArmor)
221
221
  commands.push(BWRAP_APPARMOR);
222
- const what = [need.length ? `install ${need.join(' and ')}` : '', appArmor ? 'let /usr/bin/bwrap create user namespaces under AppArmor (this program only)' : ''].filter(Boolean);
223
- const zhWhat = [need.length ? `安装 ${need.join(' 和 ')}` : '', appArmor ? '在 AppArmor 里只放行 /usr/bin/bwrap 创建用户命名空间' : ''].filter(Boolean);
222
+ const en = [need.length ? `needs ${need.join(' and ')}` : '', appArmor ? 'AppArmor blocks bwrap' : ''].filter(Boolean).join(', ');
223
+ const zh = [need.length ? `缺 ${need.join('、')}` : '', appArmor ? 'AppArmor 拦了沙箱' : ''].filter(Boolean).join(',');
224
224
  return {
225
- en: `${name}'s sandbox cannot start on this machine${st.missing?.length ? ` (${st.missing.join(', ')} missing)` : ''}. Fix: ${what.join(', then ')}.`,
226
- zh: `${name} 的沙箱在这台机器上起不来${st.missing?.length ? `(缺 ${st.missing.join('、')})` : ''}。修复:${zhWhat.join(',然后')}。`,
225
+ en: `${name}'s sandbox cannot start: ${en}.`,
226
+ zh: `${name} 的沙箱起不来:${zh}。`,
227
227
  commands,
228
228
  };
229
229
  }
@@ -32,3 +32,23 @@ export declare function windowsScript(s: ServiceSpec): string;
32
32
  export declare function serviceFile(s: ServiceSpec, dir?: string, env?: NodeJS.ProcessEnv): ServiceFile;
33
33
  /** Write the unit file (mode 0644; it holds no secrets). */
34
34
  export declare function writeServiceFile(f: ServiceFile): void;
35
+ export interface FoundService {
36
+ path: string;
37
+ /** From the file name: what serviceLabel / the unit name made of the agent's name. */
38
+ agentName: string;
39
+ command: string[];
40
+ args: string[];
41
+ env: Record<string, string>;
42
+ logFile: string;
43
+ /** `npx`: starts `npx -y <spec>`, so a restart gets the newest release. `node-cache`: starts node on a file inside the npx cache, which only ever
44
+ * runs the release that was cached. `node`: node on some other install. */
45
+ format: 'npx' | 'node-cache' | 'node' | 'unknown';
46
+ }
47
+ /** Where this platform keeps the files serviceFile() writes. */
48
+ export declare function serviceDir(platform: ServiceSpec['platform'], env?: NodeJS.ProcessEnv): string;
49
+ /** Read a service file this module wrote (launchd plist or systemd unit); null for anything else, a Windows script included. */
50
+ export declare function parseServiceFile(path: string, platform: ServiceSpec['platform'], text: string): FoundService | null;
51
+ /** Every runner service of this user on this machine (launchd and systemd). */
52
+ export declare function listServices(platform: ServiceSpec['platform'], dir?: string, env?: NodeJS.ProcessEnv): FoundService[];
53
+ /** The ServiceSpec that would write this service again (so it can be rewritten with another command). */
54
+ export declare function specOf(s: FoundService, platform: ServiceSpec['platform'], command?: string[]): ServiceSpec;
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
  function xml(s) {
@@ -140,3 +140,99 @@ export function writeServiceFile(f) {
140
140
  mkdirSync(dir, { recursive: true });
141
141
  writeFileSync(f.path, f.content, { mode: 0o644 });
142
142
  }
143
+ /** Where this platform keeps the files serviceFile() writes. */
144
+ export function serviceDir(platform, env = process.env) {
145
+ if (platform === 'win32')
146
+ return join(env.APPDATA || join(homedir(), 'AppData', 'Roaming'), 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup');
147
+ if (platform === 'darwin')
148
+ return join(homedir(), 'Library', 'LaunchAgents');
149
+ return join(homedir(), '.config', 'systemd', 'user');
150
+ }
151
+ const unxml = (s) => s.replace(/&quot;/g, '"').replace(/&gt;/g, '>').replace(/&lt;/g, '<').replace(/&amp;/g, '&');
152
+ /** The inverse of sd(): words of an ExecStart line, quoted or not. */
153
+ function sdWords(line) {
154
+ const out = [];
155
+ const re = /"((?:[^"\\]|\\.)*)"|(\S+)/g;
156
+ let m;
157
+ while ((m = re.exec(line)))
158
+ out.push(m[1] !== undefined ? m[1].replace(/\\(.)/g, '$1').replace(/\$\$/g, '$').replace(/%%/g, '%') : m[2]);
159
+ return out;
160
+ }
161
+ const formatOf = (command) => {
162
+ if (!command.length)
163
+ return 'unknown';
164
+ if (/(^|[\\/])npx(\.cmd)?$/.test(command[0]))
165
+ return 'npx';
166
+ return command.slice(1).some((x) => /[\\/]_npx[\\/]/.test(x)) ? 'node-cache' : 'node';
167
+ };
168
+ /** Read a service file this module wrote (launchd plist or systemd unit); null for anything else, a Windows script included. */
169
+ export function parseServiceFile(path, platform, text) {
170
+ let words = [];
171
+ const env = {};
172
+ let logFile = '';
173
+ let agentName = '';
174
+ if (platform === 'darwin') {
175
+ const label = /<key>Label<\/key>\s*<string>([^<]*)<\/string>/.exec(text)?.[1];
176
+ if (!label?.startsWith('io.agentchannel.runner.'))
177
+ return null;
178
+ agentName = unxml(label).slice('io.agentchannel.runner.'.length);
179
+ const arr = /<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/.exec(text)?.[1] ?? '';
180
+ words = [...arr.matchAll(/<string>([^<]*)<\/string>/g)].map((m) => unxml(m[1]));
181
+ const dict = /<key>EnvironmentVariables<\/key>\s*<dict>([\s\S]*?)<\/dict>/.exec(text)?.[1] ?? '';
182
+ for (const m of dict.matchAll(/<key>([^<]*)<\/key>\s*<string>([^<]*)<\/string>/g))
183
+ env[unxml(m[1])] = unxml(m[2]);
184
+ logFile = unxml(/<key>StandardOutPath<\/key>\s*<string>([^<]*)<\/string>/.exec(text)?.[1] ?? '');
185
+ }
186
+ else if (platform === 'linux') {
187
+ const exec = /^ExecStart=(.*)$/m.exec(text)?.[1];
188
+ const base = path.split(/[\\/]/).pop() ?? '';
189
+ if (!exec || !/^agentchannel-runner-.+\.service$/.test(base))
190
+ return null;
191
+ agentName = base.slice('agentchannel-runner-'.length, -'.service'.length);
192
+ words = sdWords(exec);
193
+ for (const m of text.matchAll(/^Environment=(.*)$/gm)) {
194
+ const kv = sdWords(m[1])[0] ?? '';
195
+ const i = kv.indexOf('=');
196
+ if (i > 0)
197
+ env[kv.slice(0, i)] = kv.slice(i + 1);
198
+ }
199
+ }
200
+ else
201
+ return null;
202
+ const run = words.indexOf('run');
203
+ if (run < 1)
204
+ return null;
205
+ const command = words.slice(0, run);
206
+ return { path, agentName, command, args: words.slice(run + 1), env, logFile, format: formatOf(command) };
207
+ }
208
+ /** Every runner service of this user on this machine (launchd and systemd). */
209
+ export function listServices(platform, dir, env = process.env) {
210
+ if (platform === 'win32')
211
+ return [];
212
+ const d = dir ?? serviceDir(platform, env);
213
+ let names = [];
214
+ try {
215
+ names = readdirSync(d);
216
+ }
217
+ catch {
218
+ return [];
219
+ }
220
+ const out = [];
221
+ for (const f of names) {
222
+ if (!(platform === 'darwin' ? /^io\.agentchannel\.runner\..+\.plist$/ : /^agentchannel-runner-.+\.service$/).test(f))
223
+ continue;
224
+ try {
225
+ const s = parseServiceFile(join(d, f), platform, readFileSync(join(d, f), 'utf8'));
226
+ if (s)
227
+ out.push(s);
228
+ }
229
+ catch {
230
+ /* unreadable: not ours to judge */
231
+ }
232
+ }
233
+ return out;
234
+ }
235
+ /** The ServiceSpec that would write this service again (so it can be rewritten with another command). */
236
+ export function specOf(s, platform, command = s.command) {
237
+ return { platform, agentName: s.agentName, command, args: s.args, env: s.env, logFile: s.logFile };
238
+ }
@@ -0,0 +1,41 @@
1
+ import { type FoundService, type ServiceSpec } from './service.js';
2
+ /** The config dir this service's runner uses: its own AGENTCHANNEL_HOME, else the default one. It names the agent (one key per home). */
3
+ export declare function homeOfService(s: FoundService, env?: NodeJS.ProcessEnv): string;
4
+ export interface Retired {
5
+ agentName: string;
6
+ path: string;
7
+ }
8
+ /**
9
+ * Stop and delete the other services of the agent whose home this is (not the one at `ownPath`). `run` false only deletes the files
10
+ * (the installer was told not to load anything). Returns what was retired.
11
+ */
12
+ export declare function retireSiblings(o: {
13
+ platform: ServiceSpec['platform'];
14
+ dir?: string;
15
+ env: NodeJS.ProcessEnv;
16
+ ownPath: string;
17
+ ownHome: string;
18
+ run: boolean;
19
+ }): Retired[];
20
+ /** Was this process started by its own service (launchd, systemd)? A runner someone started by hand must not be replaced by a restart. */
21
+ export declare function startedByService(agentName: string, platform: string, env: NodeJS.ProcessEnv): boolean;
22
+ export interface Migration {
23
+ path: string;
24
+ /** Shell commands that make the supervisor load the rewritten file. */
25
+ start: string[];
26
+ }
27
+ /**
28
+ * This agent's own service, when it is in the old form (node on the npx cache): rewritten to start `npx -y <spec>`. Null when there is
29
+ * none, or it is already fine. Writing only; the caller decides when to reload (that ends this very process).
30
+ */
31
+ export declare function migrateOldService(o: {
32
+ agentName: string;
33
+ platform: ServiceSpec['platform'];
34
+ dir?: string;
35
+ env: NodeJS.ProcessEnv;
36
+ home: string;
37
+ npx: string;
38
+ spec: string;
39
+ }): Migration | null;
40
+ /** Run the reload so that it outlives this process (the reload stops it): a detached shell. */
41
+ export declare function reloadDetached(start: string[], env: NodeJS.ProcessEnv): void;
@@ -0,0 +1,69 @@
1
+ // Keeping an agent's runner service right (issue #4): one service per agent, and a service that starts `npx -y <spec>`.
2
+ // - Installing a service retires the others on this machine that run the same agent (same AGENTCHANNEL_HOME): a renamed agent
3
+ // gets a new service label, and the old one went on running beside it.
4
+ // - A service written by an early release starts node on a file inside the npx cache. A restart (including the self-update's)
5
+ // runs that same cached file, so the runner never moves to a newer release. At start the runner rewrites such a service to
6
+ // `npx -y <spec>` and has the supervisor reload it.
7
+ import { execFileSync, spawn } from 'node:child_process';
8
+ import { unlinkSync } from 'node:fs';
9
+ import { achHome } from './config.js';
10
+ import { listServices, serviceFile, serviceLabel, specOf, writeServiceFile } from './service.js';
11
+ /** The config dir this service's runner uses: its own AGENTCHANNEL_HOME, else the default one. It names the agent (one key per home). */
12
+ export function homeOfService(s, env = process.env) {
13
+ return s.env.AGENTCHANNEL_HOME || achHome({ ...env, AGENTCHANNEL_HOME: '' });
14
+ }
15
+ /**
16
+ * Stop and delete the other services of the agent whose home this is (not the one at `ownPath`). `run` false only deletes the files
17
+ * (the installer was told not to load anything). Returns what was retired.
18
+ */
19
+ export function retireSiblings(o) {
20
+ const out = [];
21
+ for (const s of listServices(o.platform, o.dir, o.env)) {
22
+ if (s.path === o.ownPath || homeOfService(s, o.env) !== o.ownHome)
23
+ continue;
24
+ if (o.run) {
25
+ for (const c of serviceFile(specOf(s, o.platform), o.dir, o.env).stop) {
26
+ try {
27
+ execFileSync('/bin/sh', ['-c', c], { env: o.env, stdio: 'ignore' });
28
+ }
29
+ catch {
30
+ /* it was not loaded: the file still goes */
31
+ }
32
+ }
33
+ }
34
+ try {
35
+ unlinkSync(s.path);
36
+ out.push({ agentName: s.agentName, path: s.path });
37
+ }
38
+ catch {
39
+ /* cannot delete: left for the person */
40
+ }
41
+ }
42
+ return out;
43
+ }
44
+ /** Was this process started by its own service (launchd, systemd)? A runner someone started by hand must not be replaced by a restart. */
45
+ export function startedByService(agentName, platform, env) {
46
+ if (platform === 'darwin')
47
+ return env.XPC_SERVICE_NAME === serviceLabel(agentName);
48
+ if (platform === 'linux')
49
+ return !!env.INVOCATION_ID;
50
+ return false;
51
+ }
52
+ /**
53
+ * This agent's own service, when it is in the old form (node on the npx cache): rewritten to start `npx -y <spec>`. Null when there is
54
+ * none, or it is already fine. Writing only; the caller decides when to reload (that ends this very process).
55
+ */
56
+ export function migrateOldService(o) {
57
+ const name = o.agentName.replace(/[^A-Za-z0-9-]/g, '-');
58
+ const own = listServices(o.platform, o.dir, o.env).find((s) => s.agentName === name && homeOfService(s, o.env) === o.home);
59
+ if (!own || own.format !== 'node-cache')
60
+ return null;
61
+ const f = serviceFile(specOf(own, o.platform, [o.npx, '-y', o.spec]), o.dir, o.env);
62
+ writeServiceFile(f);
63
+ return { path: f.path, start: f.start };
64
+ }
65
+ /** Run the reload so that it outlives this process (the reload stops it): a detached shell. */
66
+ export function reloadDetached(start, env) {
67
+ const child = spawn('/bin/sh', ['-c', `sleep 2; ${start.join(' && ')}`], { env, detached: true, stdio: 'ignore' });
68
+ child.unref();
69
+ }
@@ -1,4 +1,4 @@
1
- export declare const VERSION = "3.12.0";
1
+ export declare const VERSION = "3.13.1";
2
2
  /** The npm package (scripts/release.mjs keeps it and VERSION in step with package.json). */
3
3
  export declare const PKG_NAME = "@koalaauto/agentchannel";
4
4
  /**
@@ -1,4 +1,4 @@
1
- export const VERSION = '3.12.0';
1
+ export const VERSION = '3.13.1';
2
2
  /** The npm package (scripts/release.mjs keeps it and VERSION in step with package.json). */
3
3
  export const PKG_NAME = '@koalaauto/agentchannel';
4
4
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@koalaauto/agentchannel",
3
- "version": "3.12.0",
3
+ "version": "3.13.1",
4
4
  "description": "Hand work between your team's agents (Claude Code, Codex) and follow it in one panel: the ach CLI, MCP server and runner for AgentChannel v2",
5
5
  "keywords": [
6
6
  "agentchannel",