@koalaauto/agentchannel 0.0.0-stage → 3.0.0-rc.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.
Files changed (71) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +154 -2
  3. package/dist/src/adapters/claude.d.ts +65 -0
  4. package/dist/src/adapters/claude.js +243 -0
  5. package/dist/src/adapters/codex.d.ts +74 -0
  6. package/dist/src/adapters/codex.js +237 -0
  7. package/dist/src/adapters/fake.d.ts +65 -0
  8. package/dist/src/adapters/fake.js +105 -0
  9. package/dist/src/adapters/index.d.ts +11 -0
  10. package/dist/src/adapters/index.js +26 -0
  11. package/dist/src/adapters/paths.d.ts +35 -0
  12. package/dist/src/adapters/paths.js +113 -0
  13. package/dist/src/adapters/procgroup.d.ts +14 -0
  14. package/dist/src/adapters/procgroup.js +49 -0
  15. package/dist/src/adapters/types.d.ts +57 -0
  16. package/dist/src/adapters/types.js +1 -0
  17. package/dist/src/args.d.ts +14 -0
  18. package/dist/src/args.js +257 -0
  19. package/dist/src/attachments.d.ts +63 -0
  20. package/dist/src/attachments.js +227 -0
  21. package/dist/src/attention.d.ts +85 -0
  22. package/dist/src/attention.js +262 -0
  23. package/dist/src/avatars.d.ts +40 -0
  24. package/dist/src/avatars.js +90 -0
  25. package/dist/src/cli.d.ts +45 -0
  26. package/dist/src/cli.js +1822 -0
  27. package/dist/src/client.d.ts +520 -0
  28. package/dist/src/client.js +449 -0
  29. package/dist/src/config.d.ts +62 -0
  30. package/dist/src/config.js +121 -0
  31. package/dist/src/desknotify.d.ts +154 -0
  32. package/dist/src/desknotify.js +426 -0
  33. package/dist/src/doctor.d.ts +75 -0
  34. package/dist/src/doctor.js +375 -0
  35. package/dist/src/env.d.ts +31 -0
  36. package/dist/src/env.js +52 -0
  37. package/dist/src/import.d.ts +245 -0
  38. package/dist/src/import.js +1008 -0
  39. package/dist/src/index.d.ts +13 -0
  40. package/dist/src/index.js +13 -0
  41. package/dist/src/invite.d.ts +103 -0
  42. package/dist/src/invite.js +229 -0
  43. package/dist/src/invite_person.d.ts +16 -0
  44. package/dist/src/invite_person.js +75 -0
  45. package/dist/src/keystore.d.ts +55 -0
  46. package/dist/src/keystore.js +108 -0
  47. package/dist/src/mcp.d.ts +62 -0
  48. package/dist/src/mcp.js +503 -0
  49. package/dist/src/names.d.ts +30 -0
  50. package/dist/src/names.js +108 -0
  51. package/dist/src/netproxy.d.ts +20 -0
  52. package/dist/src/netproxy.js +36 -0
  53. package/dist/src/onboard.d.ts +105 -0
  54. package/dist/src/onboard.js +270 -0
  55. package/dist/src/perm.d.ts +12 -0
  56. package/dist/src/perm.js +20 -0
  57. package/dist/src/prompts.d.ts +166 -0
  58. package/dist/src/prompts.js +409 -0
  59. package/dist/src/runner/runner.d.ts +212 -0
  60. package/dist/src/runner/runner.js +1148 -0
  61. package/dist/src/runner/state.d.ts +68 -0
  62. package/dist/src/runner/state.js +80 -0
  63. package/dist/src/runner/workspace.d.ts +70 -0
  64. package/dist/src/runner/workspace.js +265 -0
  65. package/dist/src/service.d.ts +29 -0
  66. package/dist/src/service.js +101 -0
  67. package/dist/src/version.d.ts +3 -0
  68. package/dist/src/version.js +3 -0
  69. package/dist/src/wiki.d.ts +74 -0
  70. package/dist/src/wiki.js +222 -0
  71. package/package.json +48 -4
@@ -0,0 +1,68 @@
1
+ export interface RunRecord {
2
+ taskId: string;
3
+ runId: string;
4
+ phase: 'running' | 'parked';
5
+ workdir: string;
6
+ runToken: string;
7
+ tokenAt: number;
8
+ handoffVersion?: number;
9
+ /** The agent stopped once without finishing and was asked to finish (runner afterWork). */
10
+ nudged?: boolean;
11
+ /** Message ids the worker has already been shown. */
12
+ seen: string[];
13
+ /** S1: waiting for the worker owner to approve agent-config changes in links.base. */
14
+ gate?: {
15
+ requestId: string;
16
+ files: string[];
17
+ };
18
+ /** Cross-repo: other repos readable for this task (links.repos). */
19
+ readRepos?: ReadRepo[];
20
+ /** Attachment URLs the worker has already been shown. */
21
+ files?: string[];
22
+ }
23
+ /** A read-only repo of a cross-repo task: shown to the agent at `path`; `roots` are made readable. */
24
+ export interface ReadRepo {
25
+ name: string;
26
+ path: string;
27
+ roots: string[];
28
+ }
29
+ export interface RunsFile {
30
+ runs: Record<string, RunRecord>;
31
+ /** Question ids we already ran (or permanently skipped) an answer run for, with their task. */
32
+ handledQuestions: Record<string, {
33
+ task: string;
34
+ at: string;
35
+ } | string>;
36
+ /** Tasks that already got the one budget decision_request (§14.6). */
37
+ budgetNotified: Record<string, string>;
38
+ }
39
+ export interface SessionRef {
40
+ id: string;
41
+ cwd: string;
42
+ }
43
+ export interface SessionsFile {
44
+ [taskId: string]: {
45
+ work?: SessionRef;
46
+ answer?: SessionRef;
47
+ };
48
+ }
49
+ /** Persisted runner state: runs.json and sessions.json, both mode 0600. */
50
+ export declare class RunnerState {
51
+ readonly home: string;
52
+ readonly runsPath: string;
53
+ readonly sessionsPath: string;
54
+ runs: RunsFile;
55
+ sessions: SessionsFile;
56
+ constructor(home: string);
57
+ saveRuns(): void;
58
+ saveSessions(): void;
59
+ get(taskId: string): RunRecord | undefined;
60
+ put(rec: RunRecord): void;
61
+ drop(taskId: string): void;
62
+ /** Task ids referenced by bookkeeping entries (handled questions, budget notices). */
63
+ bookkeepingTasks(): string[];
64
+ /** C8: forget bookkeeping (and sessions) of tasks that are terminal or gone. */
65
+ pruneTasks(taskIds: string[]): void;
66
+ setSession(taskId: string, kind: 'work' | 'answer', ref: SessionRef): void;
67
+ session(taskId: string, kind: 'work' | 'answer'): SessionRef | undefined;
68
+ }
@@ -0,0 +1,80 @@
1
+ import { join } from 'node:path';
2
+ import { readJson, writeJsonPrivate } from '../config.js';
3
+ /** Persisted runner state: runs.json and sessions.json, both mode 0600. */
4
+ export class RunnerState {
5
+ home;
6
+ runsPath;
7
+ sessionsPath;
8
+ runs;
9
+ sessions;
10
+ constructor(home) {
11
+ this.home = home;
12
+ this.runsPath = join(home, 'runs.json');
13
+ this.sessionsPath = join(home, 'sessions.json');
14
+ const r = readJson(this.runsPath, {});
15
+ this.runs = { runs: r.runs ?? {}, handledQuestions: r.handledQuestions ?? {}, budgetNotified: r.budgetNotified ?? {} };
16
+ this.sessions = readJson(this.sessionsPath, {});
17
+ }
18
+ saveRuns() {
19
+ writeJsonPrivate(this.runsPath, this.runs);
20
+ }
21
+ saveSessions() {
22
+ writeJsonPrivate(this.sessionsPath, this.sessions);
23
+ }
24
+ get(taskId) {
25
+ return this.runs.runs[taskId];
26
+ }
27
+ put(rec) {
28
+ this.runs.runs[rec.taskId] = rec;
29
+ this.saveRuns();
30
+ }
31
+ drop(taskId) {
32
+ if (this.runs.runs[taskId]) {
33
+ delete this.runs.runs[taskId];
34
+ this.saveRuns();
35
+ }
36
+ }
37
+ /** Task ids referenced by bookkeeping entries (handled questions, budget notices). */
38
+ bookkeepingTasks() {
39
+ const ids = new Set(Object.keys(this.runs.budgetNotified));
40
+ for (const v of Object.values(this.runs.handledQuestions))
41
+ if (typeof v === 'object' && v?.task)
42
+ ids.add(v.task);
43
+ return [...ids];
44
+ }
45
+ /** C8: forget bookkeeping (and sessions) of tasks that are terminal or gone. */
46
+ pruneTasks(taskIds) {
47
+ if (!taskIds.length)
48
+ return;
49
+ const drop = new Set(taskIds);
50
+ for (const id of taskIds) {
51
+ delete this.runs.budgetNotified[id];
52
+ delete this.runs.runs[id];
53
+ }
54
+ for (const [q, v] of Object.entries(this.runs.handledQuestions)) {
55
+ if (typeof v === 'object' && drop.has(v.task))
56
+ delete this.runs.handledQuestions[q];
57
+ }
58
+ this.saveRuns();
59
+ let sessions = false;
60
+ for (const id of taskIds) {
61
+ if (this.sessions[id]) {
62
+ delete this.sessions[id];
63
+ sessions = true;
64
+ }
65
+ }
66
+ if (sessions)
67
+ this.saveSessions();
68
+ }
69
+ setSession(taskId, kind, ref) {
70
+ const cur = this.sessions[taskId] ?? {};
71
+ if (cur[kind]?.id === ref.id && cur[kind]?.cwd === ref.cwd)
72
+ return;
73
+ cur[kind] = ref;
74
+ this.sessions[taskId] = cur;
75
+ this.saveSessions();
76
+ }
77
+ session(taskId, kind) {
78
+ return this.sessions[taskId]?.[kind];
79
+ }
80
+ }
@@ -0,0 +1,70 @@
1
+ import type { Task } from '../client.js';
2
+ import type { ReadRepo } from './state.js';
3
+ export declare function isGitRepo(dir: string): Promise<boolean>;
4
+ export declare function branchName(agentName: string, taskId: string): string;
5
+ export interface Workspace {
6
+ prepare(task: Task, agentName: string): Promise<string>;
7
+ /** Remove the work dir for a task (the branch is kept). */
8
+ remove(taskId: string): Promise<void>;
9
+ /** Task ids that have a work dir. */
10
+ list(): string[];
11
+ /** mtime of a task's work dir. */
12
+ mtime(taskId: string): number;
13
+ /**
14
+ * S1: agent-config files (.claude/, .mcp.json, CLAUDE.md, AGENTS.md) that the
15
+ * task's links.base adds or changes relative to the runner's HEAD.
16
+ */
17
+ sensitiveChanges?(task: Task): Promise<string[]>;
18
+ /**
19
+ * Cross-repo: the task's links.repos (besides the primary repo), made readable.
20
+ * Unknown names throw (the task fails with the message).
21
+ */
22
+ readRepos?(task: Task): Promise<ReadRepo[]>;
23
+ /** S10: commit the work dir's changes on the task branch after a run. Returns the commit sha, if any. */
24
+ commit?(taskId: string, message: string): Promise<string | undefined>;
25
+ }
26
+ /** Paths whose change by a sender-chosen base needs the worker owner's approval (S1). */
27
+ export declare function isSensitivePath(p: string): boolean;
28
+ export declare const REPO_NAME_RE: RegExp;
29
+ /** P3: parse `--repo name=path` values (relative paths resolve against cwd). */
30
+ export declare function parseRepoFlags(values: string[], cwd?: string): Record<string, string>;
31
+ /**
32
+ * Per-task git worktree (from links.base or HEAD) or a plain per-task dir (§6 / §14.14).
33
+ * P3: `links.repo` names the repo; `repos` maps names to local paths. A task
34
+ * without links.repo uses `dir`. An unknown name is an error (the task fails).
35
+ */
36
+ export declare class GitWorkspace implements Workspace {
37
+ readonly dir: string;
38
+ private log;
39
+ readonly repos: Record<string, string>;
40
+ readonly root: string;
41
+ constructor(dir: string, home: string, log?: (s: string) => void, repos?: Record<string, string>);
42
+ /** The local repo a task works in (P3). */
43
+ repoDir(task: Task): string;
44
+ /** Map a repo name to its local path, or fail with the names this runner knows. */
45
+ lookup(r: unknown, what: string): string;
46
+ /** Where read-only checkouts of a task's other repos go (next to, not inside, its worktree). */
47
+ refRoot(taskId: string): string;
48
+ /**
49
+ * Cross-repo: links.repos = [{repo, base?}]. The primary repo (links.repo or
50
+ * --dir) is skipped. With a base, the repo is checked out detached at that
51
+ * base into <work>/<taskId>.ref/<name> (a git worktree; the agent can read it,
52
+ * never write it). Without a base, the mapped repo itself is made readable.
53
+ */
54
+ readRepos(task: Task): Promise<ReadRepo[]>;
55
+ path(taskId: string): string;
56
+ prepare(task: Task, agentName: string): Promise<string>;
57
+ private resolveRef;
58
+ /** The main repo a worktree belongs to (any configured repo), from its git common dir. */
59
+ private ownerRepo;
60
+ remove(taskId: string): Promise<void>;
61
+ sensitiveChanges(task: Task): Promise<string[]>;
62
+ /**
63
+ * S10: agents cannot write the main repo's .git, so the runner commits for
64
+ * them. Hooks are disabled (core.hooksPath=/dev/null, --no-verify): the work
65
+ * dir content is agent-written and must never run outside the sandbox.
66
+ */
67
+ commit(taskId: string, message: string): Promise<string | undefined>;
68
+ list(): string[];
69
+ mtime(taskId: string): number;
70
+ }
@@ -0,0 +1,265 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'node:fs';
3
+ import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
4
+ import { promisify } from 'node:util';
5
+ const pexec = promisify(execFile);
6
+ async function git(cwd, args) {
7
+ const { stdout } = await pexec('git', args, { cwd, encoding: 'utf8' });
8
+ return stdout.trim();
9
+ }
10
+ export async function isGitRepo(dir) {
11
+ try {
12
+ return (await git(dir, ['rev-parse', '--is-inside-work-tree'])) === 'true';
13
+ }
14
+ catch {
15
+ return false;
16
+ }
17
+ }
18
+ export function branchName(agentName, taskId) {
19
+ return `ach/${agentName}/${taskId.slice(0, 8)}`;
20
+ }
21
+ /** Paths whose change by a sender-chosen base needs the worker owner's approval (S1). */
22
+ export function isSensitivePath(p) {
23
+ return /^\.claude(\/|$)/.test(p) || p === '.mcp.json' || /(^|\/)(CLAUDE|AGENTS)(\.local)?\.md$/i.test(p) || /(^|\/)\.codex(\/|$)/.test(p);
24
+ }
25
+ export const REPO_NAME_RE = /^[A-Za-z0-9._-]{1,64}$/;
26
+ /** P3: parse `--repo name=path` values (relative paths resolve against cwd). */
27
+ export function parseRepoFlags(values, cwd = process.cwd()) {
28
+ const out = {};
29
+ for (const v of values) {
30
+ const i = v.indexOf('=');
31
+ const name = i > 0 ? v.slice(0, i).trim() : '';
32
+ const path = i > 0 ? v.slice(i + 1).trim() : '';
33
+ if (!REPO_NAME_RE.test(name) || !path)
34
+ throw new Error(`--repo must be name=path (got "${v}")`);
35
+ out[name] = resolve(cwd, path);
36
+ }
37
+ return out;
38
+ }
39
+ /**
40
+ * Per-task git worktree (from links.base or HEAD) or a plain per-task dir (§6 / §14.14).
41
+ * P3: `links.repo` names the repo; `repos` maps names to local paths. A task
42
+ * without links.repo uses `dir`. An unknown name is an error (the task fails).
43
+ */
44
+ export class GitWorkspace {
45
+ dir;
46
+ log;
47
+ repos;
48
+ root;
49
+ constructor(dir, home, log = () => { }, repos = {}) {
50
+ this.dir = dir;
51
+ this.log = log;
52
+ this.repos = repos;
53
+ this.root = join(home, 'work');
54
+ }
55
+ /** The local repo a task works in (P3). */
56
+ repoDir(task) {
57
+ const r = task.links?.repo;
58
+ if (r === undefined || r === null || r === '')
59
+ return this.dir;
60
+ return this.lookup(r, 'links.repo');
61
+ }
62
+ /** Map a repo name to its local path, or fail with the names this runner knows. */
63
+ lookup(r, what) {
64
+ if (typeof r !== 'string' || !REPO_NAME_RE.test(r))
65
+ throw new Error(`invalid ${what} ${JSON.stringify(r)}`);
66
+ if (this.repos[r])
67
+ return this.repos[r];
68
+ if (basename(this.dir) === r)
69
+ return this.dir;
70
+ const known = [...Object.keys(this.repos), basename(this.dir)].join(', ');
71
+ throw new Error(`unknown repo "${r}": this runner knows ${known || '(none)'}. Restart it with --repo ${r}=<path> (or add it to "repos" in config.json).`);
72
+ }
73
+ /** Where read-only checkouts of a task's other repos go (next to, not inside, its worktree). */
74
+ refRoot(taskId) {
75
+ return `${this.path(taskId)}.ref`;
76
+ }
77
+ /**
78
+ * Cross-repo: links.repos = [{repo, base?}]. The primary repo (links.repo or
79
+ * --dir) is skipped. With a base, the repo is checked out detached at that
80
+ * base into <work>/<taskId>.ref/<name> (a git worktree; the agent can read it,
81
+ * never write it). Without a base, the mapped repo itself is made readable.
82
+ */
83
+ async readRepos(task) {
84
+ const list = task.links?.repos;
85
+ if (list === undefined || list === null)
86
+ return [];
87
+ if (!Array.isArray(list))
88
+ throw new Error('links.repos must be a list of {repo, base?}');
89
+ const primary = this.repoDir(task);
90
+ const out = [];
91
+ for (const e of list) {
92
+ const name = e?.repo;
93
+ const dir = this.lookup(name, 'links.repos[].repo');
94
+ if (dir === primary || out.some((x) => x.name === name))
95
+ continue;
96
+ const base = e?.base;
97
+ if (base === undefined || base === null || base === '' || !(await isGitRepo(dir))) {
98
+ out.push({ name, path: dir, roots: [dir] });
99
+ continue;
100
+ }
101
+ if (typeof base !== 'string' || !/^[A-Za-z0-9._\/-]+$/.test(base) || base.startsWith('-'))
102
+ throw new Error(`invalid base for repo ${name}: ${JSON.stringify(base)}`);
103
+ const p = join(this.refRoot(task.id), name);
104
+ if (!existsSync(p)) {
105
+ mkdirSync(this.refRoot(task.id), { recursive: true, mode: 0o700 });
106
+ try {
107
+ await git(dir, ['fetch', '--all', '--prune']);
108
+ }
109
+ catch (err) {
110
+ this.log(`git fetch failed in ${dir}: ${err?.message ?? err}`);
111
+ }
112
+ const start = await this.resolveRef(dir, base);
113
+ await git(dir, ['worktree', 'add', '--detach', p, start]);
114
+ }
115
+ const common = await git(p, ['rev-parse', '--path-format=absolute', '--git-common-dir']).catch(() => '');
116
+ out.push({ name, path: p, roots: [p, ...(common ? [common] : [])] });
117
+ }
118
+ return out;
119
+ }
120
+ path(taskId) {
121
+ if (!/^[A-Za-z0-9_-]+$/.test(taskId))
122
+ throw new Error(`bad task id ${taskId}`);
123
+ return join(this.root, taskId);
124
+ }
125
+ async prepare(task, agentName) {
126
+ const p = this.path(task.id);
127
+ const dir = this.repoDir(task);
128
+ if (existsSync(p))
129
+ return p;
130
+ mkdirSync(this.root, { recursive: true, mode: 0o700 });
131
+ if (!(await isGitRepo(dir))) {
132
+ mkdirSync(p, { recursive: true, mode: 0o700 });
133
+ return p;
134
+ }
135
+ const branch = branchName(agentName, task.id);
136
+ const base = typeof task.links?.base === 'string' ? task.links.base : undefined;
137
+ let start = 'HEAD';
138
+ if (base) {
139
+ if (!/^[A-Za-z0-9._\/-]+$/.test(base) || base.startsWith('-'))
140
+ throw new Error(`invalid links.base "${base}"`);
141
+ try {
142
+ await git(dir, ['fetch', '--all', '--prune']);
143
+ }
144
+ catch (e) {
145
+ this.log(`git fetch failed: ${e?.message ?? e}`);
146
+ }
147
+ start = await this.resolveRef(dir, base);
148
+ }
149
+ let branchExists = false;
150
+ try {
151
+ await git(dir, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`]);
152
+ branchExists = true;
153
+ }
154
+ catch {
155
+ /* new branch */
156
+ }
157
+ if (branchExists)
158
+ await git(dir, ['worktree', 'add', p, branch]);
159
+ else
160
+ await git(dir, ['worktree', 'add', '-b', branch, p, start]);
161
+ return p;
162
+ }
163
+ async resolveRef(dir, base) {
164
+ for (const cand of [base, `origin/${base}`]) {
165
+ try {
166
+ await git(dir, ['rev-parse', '--verify', '--quiet', `${cand}^{commit}`]);
167
+ return cand;
168
+ }
169
+ catch {
170
+ /* try next */
171
+ }
172
+ }
173
+ throw new Error(`links.base "${base}" not found in ${dir}`);
174
+ }
175
+ /** The main repo a worktree belongs to (any configured repo), from its git common dir. */
176
+ async ownerRepo(p) {
177
+ try {
178
+ const common = await git(p, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
179
+ const abs = isAbsolute(common) ? common : resolve(p, common);
180
+ return basename(abs) === '.git' ? dirname(abs) : abs;
181
+ }
182
+ catch {
183
+ return undefined;
184
+ }
185
+ }
186
+ async remove(taskId) {
187
+ const p = this.path(taskId);
188
+ const ref = this.refRoot(taskId);
189
+ if (existsSync(ref)) {
190
+ for (const n of readdirSync(ref)) {
191
+ const rp = join(ref, n);
192
+ const owner = await this.ownerRepo(rp);
193
+ if (owner && (await isGitRepo(owner)))
194
+ await git(owner, ['worktree', 'remove', '--force', rp]).catch(() => '');
195
+ }
196
+ rmSync(ref, { recursive: true, force: true });
197
+ }
198
+ if (!existsSync(p))
199
+ return;
200
+ const owner = (await this.ownerRepo(p)) ?? this.dir;
201
+ if (await isGitRepo(owner)) {
202
+ try {
203
+ await git(owner, ['worktree', 'remove', '--force', p]);
204
+ return;
205
+ }
206
+ catch {
207
+ /* not a worktree of this repo; fall through */
208
+ }
209
+ }
210
+ rmSync(p, { recursive: true, force: true });
211
+ for (const d of new Set([owner, this.dir, ...Object.values(this.repos)])) {
212
+ if (await isGitRepo(d))
213
+ await git(d, ['worktree', 'prune']).catch(() => '');
214
+ }
215
+ }
216
+ async sensitiveChanges(task) {
217
+ const base = typeof task.links?.base === 'string' ? task.links.base : undefined;
218
+ const dir = this.repoDir(task);
219
+ if (!base || !(await isGitRepo(dir)))
220
+ return [];
221
+ const start = await this.resolveRef(dir, base);
222
+ const out = await git(dir, ['diff', '--name-only', '--no-renames', `HEAD...${start}`, '--']);
223
+ return out.split('\n').filter((l) => l && isSensitivePath(l));
224
+ }
225
+ /**
226
+ * S10: agents cannot write the main repo's .git, so the runner commits for
227
+ * them. Hooks are disabled (core.hooksPath=/dev/null, --no-verify): the work
228
+ * dir content is agent-written and must never run outside the sandbox.
229
+ */
230
+ async commit(taskId, message) {
231
+ const p = this.path(taskId);
232
+ if (!existsSync(p) || !(await isGitRepo(p)))
233
+ return undefined;
234
+ const status = await git(p, ['status', '--porcelain']);
235
+ if (!status)
236
+ return undefined;
237
+ const ident = [];
238
+ try {
239
+ await git(p, ['config', 'user.email']);
240
+ }
241
+ catch {
242
+ ident.push('-c', 'user.name=AgentChannel runner', '-c', 'user.email=runner@agentchannel.invalid');
243
+ }
244
+ const safe = ['-c', 'core.hooksPath=/dev/null', ...ident];
245
+ await git(p, [...safe, 'add', '-A']);
246
+ await git(p, [...safe, 'commit', '--no-verify', '-q', '-m', message]);
247
+ return git(p, ['rev-parse', 'HEAD']);
248
+ }
249
+ list() {
250
+ try {
251
+ return readdirSync(this.root).filter((n) => /^[A-Za-z0-9_-]+$/.test(n));
252
+ }
253
+ catch {
254
+ return [];
255
+ }
256
+ }
257
+ mtime(taskId) {
258
+ try {
259
+ return statSync(this.path(taskId)).mtimeMs;
260
+ }
261
+ catch {
262
+ return 0;
263
+ }
264
+ }
265
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * P4: `ach run --install-service` writes a macOS launchd agent or a Linux
3
+ * systemd user unit that starts this runner at login and restarts it. It only
4
+ * writes the file and prints how to start it; it never loads the unit.
5
+ */
6
+ export interface ServiceSpec {
7
+ platform: 'darwin' | 'linux';
8
+ agentName: string;
9
+ /** node binary and cli.js of this install. */
10
+ node: string;
11
+ cli: string;
12
+ /** `run` arguments, without --install-service. */
13
+ args: string[];
14
+ env: Record<string, string>;
15
+ /** Where runner output goes. */
16
+ logFile: string;
17
+ }
18
+ export interface ServiceFile {
19
+ path: string;
20
+ content: string;
21
+ start: string[];
22
+ stop: string[];
23
+ }
24
+ export declare function serviceLabel(agentName: string): string;
25
+ export declare function launchdPlist(s: ServiceSpec): string;
26
+ export declare function systemdUnit(s: ServiceSpec): string;
27
+ export declare function serviceFile(s: ServiceSpec, dir?: string): ServiceFile;
28
+ /** Write the unit file (mode 0644; it holds no secrets). Never loads it. */
29
+ export declare function writeServiceFile(f: ServiceFile): void;
@@ -0,0 +1,101 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ function xml(s) {
5
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
6
+ }
7
+ /** systemd quoting: wrap in double quotes, escape backslash, quote, dollar and percent. */
8
+ function sd(s) {
9
+ if (/^[A-Za-z0-9_./:=@,+-]+$/.test(s))
10
+ return s;
11
+ return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\$/g, '$$$$').replace(/%/g, '%%')}"`;
12
+ }
13
+ export function serviceLabel(agentName) {
14
+ return `io.agentchannel.runner.${agentName.replace(/[^A-Za-z0-9-]/g, '-')}`;
15
+ }
16
+ export function launchdPlist(s) {
17
+ const progArgs = [s.node, s.cli, 'run', ...s.args].map((a) => ` <string>${xml(a)}</string>`).join('\n');
18
+ const env = Object.entries(s.env)
19
+ .map(([k, v]) => ` <key>${xml(k)}</key>\n <string>${xml(v)}</string>`)
20
+ .join('\n');
21
+ return `<?xml version="1.0" encoding="UTF-8"?>
22
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
23
+ <plist version="1.0">
24
+ <dict>
25
+ <key>Label</key>
26
+ <string>${xml(serviceLabel(s.agentName))}</string>
27
+ <key>ProgramArguments</key>
28
+ <array>
29
+ ${progArgs}
30
+ </array>
31
+ <key>EnvironmentVariables</key>
32
+ <dict>
33
+ ${env}
34
+ </dict>
35
+ <key>RunAtLoad</key>
36
+ <true/>
37
+ <key>KeepAlive</key>
38
+ <dict>
39
+ <key>SuccessfulExit</key>
40
+ <false/>
41
+ </dict>
42
+ <key>ThrottleInterval</key>
43
+ <integer>10</integer>
44
+ <key>StandardOutPath</key>
45
+ <string>${xml(s.logFile)}</string>
46
+ <key>StandardErrorPath</key>
47
+ <string>${xml(s.logFile)}</string>
48
+ </dict>
49
+ </plist>
50
+ `;
51
+ }
52
+ export function systemdUnit(s) {
53
+ const exec = [s.node, s.cli, 'run', ...s.args].map(sd).join(' ');
54
+ const env = Object.entries(s.env)
55
+ .map(([k, v]) => `Environment=${sd(`${k}=${v}`)}`)
56
+ .join('\n');
57
+ return `[Unit]
58
+ Description=AgentChannel runner (agent ${s.agentName})
59
+ After=network-online.target
60
+ Wants=network-online.target
61
+
62
+ [Service]
63
+ Type=simple
64
+ ExecStart=${exec}
65
+ ${env}
66
+ Restart=on-failure
67
+ RestartSec=10
68
+ # B7: exit 78 = key rotated or agent deactivated; restarting cannot help.
69
+ RestartPreventExitStatus=78
70
+
71
+ [Install]
72
+ WantedBy=default.target
73
+ `;
74
+ }
75
+ export function serviceFile(s, dir) {
76
+ if (s.platform === 'darwin') {
77
+ const label = serviceLabel(s.agentName);
78
+ const path = join(dir ?? join(homedir(), 'Library', 'LaunchAgents'), `${label}.plist`);
79
+ return {
80
+ path,
81
+ content: launchdPlist(s),
82
+ start: [`launchctl bootstrap gui/$(id -u) ${path}`],
83
+ stop: [`launchctl bootout gui/$(id -u)/${label}`],
84
+ };
85
+ }
86
+ const unit = `agentchannel-runner-${s.agentName.replace(/[^A-Za-z0-9-]/g, '-')}.service`;
87
+ const path = join(dir ?? join(homedir(), '.config', 'systemd', 'user'), unit);
88
+ return {
89
+ path,
90
+ content: systemdUnit(s),
91
+ start: ['systemctl --user daemon-reload', `systemctl --user enable --now ${unit}`, `loginctl enable-linger "$USER" # optional: keep it running while logged out`],
92
+ stop: [`systemctl --user disable --now ${unit}`],
93
+ };
94
+ }
95
+ /** Write the unit file (mode 0644; it holds no secrets). Never loads it. */
96
+ export function writeServiceFile(f) {
97
+ const dir = join(f.path, '..');
98
+ if (!existsSync(dir))
99
+ mkdirSync(dir, { recursive: true });
100
+ writeFileSync(f.path, f.content, { mode: 0o644 });
101
+ }
@@ -0,0 +1,3 @@
1
+ export declare const VERSION = "3.0.0-rc.1";
2
+ /** The npm package (scripts/release.mjs keeps it and VERSION in step with package.json). */
3
+ export declare const PKG_NAME = "@koalaauto/agentchannel";
@@ -0,0 +1,3 @@
1
+ export const VERSION = '3.0.0-rc.1';
2
+ /** The npm package (scripts/release.mjs keeps it and VERSION in step with package.json). */
3
+ export const PKG_NAME = '@koalaauto/agentchannel';