@koalaauto/agentchannel 0.0.0-stage → 3.0.0

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,449 @@
1
+ import { openAsBlob, statSync } from 'node:fs';
2
+ import { basename } from 'node:path';
3
+ import { attachmentForm, bigFiles, downloadFile, isLinkRef, linkRef, taskFiles, typeOf, uploadTarget } from './attachments.js';
4
+ export const TERMINAL = ['completed', 'failed', 'canceled'];
5
+ export function isTerminal(s) {
6
+ return !!s && TERMINAL.includes(s);
7
+ }
8
+ /** Parse `name` or `name@base` (CLI `--read-repo`). */
9
+ export function parseLinkRepo(v) {
10
+ const i = v.indexOf('@');
11
+ const repo = (i >= 0 ? v.slice(0, i) : v).trim();
12
+ const base = i >= 0 ? v.slice(i + 1).trim() : '';
13
+ if (!/^[A-Za-z0-9._-]{1,64}$/.test(repo))
14
+ throw new Error(`repo must be a name, got "${v}"`);
15
+ if (i >= 0 && (!/^[A-Za-z0-9._\/-]{1,200}$/.test(base) || base.startsWith('-')))
16
+ throw new Error(`bad base in "${v}"`);
17
+ return base ? { repo, base } : { repo };
18
+ }
19
+ export class HubError extends Error {
20
+ status;
21
+ code;
22
+ body;
23
+ constructor(status, message, code, body) {
24
+ super(message);
25
+ this.status = status;
26
+ this.code = code;
27
+ this.body = body;
28
+ this.name = 'HubError';
29
+ }
30
+ }
31
+ /** Round 2: human-facing CLI text in Chinese when LANG/LC_ALL starts with zh. */
32
+ export function isZh(env) {
33
+ return /^zh/i.test(env.LC_ALL || env.LC_MESSAGES || env.LANG || '');
34
+ }
35
+ /** Picks the bearer: run token (spawned agent) beats agent key. */
36
+ export function clientFromConfig(cfg, opts = {}) {
37
+ if (!cfg.hub) {
38
+ throw new Error(isZh(process.env)
39
+ ? '还没有连接 hub:运行面板或管理员给你的 `ach join ach2....` 那一行(或设置 ACH_HUB)。'
40
+ : 'No hub configured: run the `ach join ach2....` line from the panel or your admin (or set ACH_HUB).');
41
+ }
42
+ const token = opts.admin ? cfg.adminToken : cfg.runToken || cfg.key;
43
+ return new Client({ hub: cfg.hub, token, cfId: cfg.cfId, cfSecret: cfg.cfSecret, fetch: opts.fetch });
44
+ }
45
+ function arr(v) {
46
+ return Array.isArray(v) ? v : [];
47
+ }
48
+ /** Accept either `{task, handoff, messages, history}` or a bare task record. */
49
+ export function normalizeBundle(raw) {
50
+ const task = raw?.task ?? raw;
51
+ return {
52
+ task,
53
+ handoff: typeof raw?.handoff === 'string' ? raw.handoff : raw?.handoff?.body ?? task?.handoff ?? '',
54
+ messages: arr(raw?.messages),
55
+ history: arr(raw?.handoff_history ?? raw?.history ?? raw?.handoff_versions),
56
+ handoffAttachments: arr(raw?.handoff?.attachments),
57
+ wiki: raw?.wiki_context && typeof raw.wiki_context === 'object' ? raw.wiki_context : null,
58
+ };
59
+ }
60
+ export class Client {
61
+ hub;
62
+ token;
63
+ cfId;
64
+ cfSecret;
65
+ fetchFn;
66
+ constructor(o) {
67
+ this.hub = o.hub.replace(/\/+$/, '');
68
+ this.token = o.token;
69
+ this.cfId = o.cfId;
70
+ this.cfSecret = o.cfSecret;
71
+ this.fetchFn = o.fetch ?? ((u, i) => fetch(u, i));
72
+ }
73
+ headers(json = true) {
74
+ const h = json ? { 'content-type': 'application/json', accept: 'application/json' } : { accept: 'application/json' };
75
+ if (this.token) {
76
+ // Agent keys and run tokens use Bearer; PocketBase user tokens are sent raw.
77
+ h.authorization = /^ach_(sk|rt)_/.test(this.token) ? `Bearer ${this.token}` : this.token;
78
+ }
79
+ if (this.cfId && this.cfSecret) {
80
+ h['CF-Access-Client-Id'] = this.cfId;
81
+ h['CF-Access-Client-Secret'] = this.cfSecret;
82
+ }
83
+ return h;
84
+ }
85
+ /** `files`: local paths (already checked by resolveAttachments) sent as multipart with the JSON in field "json". */
86
+ async request(method, path, body, files) {
87
+ const url = path.startsWith('http') ? path : `${this.hub}${path}`;
88
+ if (files?.length)
89
+ ({ body, files } = await this.splitAttachments(path, body, files));
90
+ const multipart = !!files?.length;
91
+ const res = await this.fetchFn(url, {
92
+ method,
93
+ headers: this.headers(!multipart),
94
+ body: multipart ? await attachmentForm(body, files) : body === undefined ? undefined : JSON.stringify(body),
95
+ });
96
+ const text = await res.text();
97
+ let data = undefined;
98
+ if (text) {
99
+ try {
100
+ data = JSON.parse(text);
101
+ }
102
+ catch {
103
+ data = text;
104
+ }
105
+ }
106
+ if (!res.ok) {
107
+ const msg = (data && (data.error || data.message)) || `${res.status} ${res.statusText}`;
108
+ throw new HubError(res.status, `${method} ${path}: ${typeof msg === 'string' ? msg : JSON.stringify(msg)}`, data?.code, data);
109
+ }
110
+ return data;
111
+ }
112
+ storageCfg;
113
+ /** GET /storage/config once per client (null on hubs without it). */
114
+ storageConfig() {
115
+ this.storageCfg ??= this.request('GET', '/api/ach/storage/config').catch(() => null);
116
+ return this.storageCfg;
117
+ }
118
+ /**
119
+ * §20 v1.1: https entries become link references; media files over the hub's
120
+ * direct-upload threshold go straight to its bucket (presign, PUT, complete)
121
+ * and become upload references. The rest is sent as multipart, as before.
122
+ */
123
+ async splitAttachments(path, body, files) {
124
+ const refs = files.filter(isLinkRef).map((f) => linkRef(f));
125
+ let local = files.filter((f) => !isLinkRef(f));
126
+ const target = uploadTarget(path);
127
+ // only ask the hub when a file could be over any threshold (>= 5 MB)
128
+ if (target && local.some((f) => statSync(f).size > 5 * 1048576)) {
129
+ const big = bigFiles(local, await this.storageConfig());
130
+ for (const f of big)
131
+ refs.push(await this.directUpload(f, target));
132
+ local = local.filter((f) => !big.includes(f));
133
+ }
134
+ if (!refs.length)
135
+ return { body, files: local };
136
+ const b = body && typeof body === 'object' ? body : {};
137
+ const prior = Array.isArray(b.attachment_refs) ? b.attachment_refs : [];
138
+ return { body: { ...b, attachment_refs: [...prior, ...refs] }, files: local };
139
+ }
140
+ /** Upload one file directly to the hub's bucket; returns the reference to attach ({upload}). */
141
+ async directUpload(file, target) {
142
+ const mime = typeOf(file);
143
+ const size = statSync(file).size;
144
+ const p = await this.request('POST', '/api/ach/uploads/presign', { name: basename(file), mime, size, target });
145
+ const put = p?.put;
146
+ if (!put?.url || !p?.upload?.id)
147
+ throw new HubError(502, `presign ${basename(file)}: unexpected answer`);
148
+ const res = await this.fetchFn(put.url, { method: 'PUT', headers: put.headers ?? { 'content-type': mime }, body: (await openAsBlob(file, { type: mime })) });
149
+ if (!res.ok) {
150
+ const text = await res.text().catch(() => '');
151
+ throw new HubError(res.status, `upload ${basename(file)} to storage: ${res.status} ${res.statusText} ${text.slice(0, 200)}`.trim());
152
+ }
153
+ const done = await this.request('POST', '/api/ach/uploads/complete', { upload: p.upload.id });
154
+ return { upload: String(done?.ref?.upload ?? p.upload.id) };
155
+ }
156
+ /** Avatars: send one image as multipart field "file" (plus plain fields). */
157
+ async sendImage(method, path, bytes, filename, fields = {}) {
158
+ const fd = new FormData();
159
+ for (const [k, v] of Object.entries(fields))
160
+ fd.append(k, v);
161
+ const type = /\.png$/i.test(filename) ? 'image/png' : /\.gif$/i.test(filename) ? 'image/gif' : /\.webp$/i.test(filename) ? 'image/webp' : 'image/jpeg';
162
+ fd.append('file', new Blob([bytes], { type }), filename);
163
+ const res = await this.fetchFn(`${this.hub}${path}`, { method, headers: this.headers(false), body: fd });
164
+ const text = await res.text();
165
+ let data;
166
+ try {
167
+ data = text ? JSON.parse(text) : undefined;
168
+ }
169
+ catch {
170
+ data = text;
171
+ }
172
+ if (!res.ok)
173
+ throw new HubError(res.status, `${method} ${path}: ${(data && (data.error || data.message)) || res.status}`, data?.code, data);
174
+ return data;
175
+ }
176
+ ach(method, path, body, files) {
177
+ return this.request(method, `/api/ach${path}`, body, files);
178
+ }
179
+ /** All attachments on a task (handoff versions and messages), with hub download URLs. */
180
+ async taskFiles(taskId) {
181
+ return taskFiles(await this.getTask(taskId));
182
+ }
183
+ /** Download one attachment into `dir`; returns the local path. */
184
+ async download(a, dir) {
185
+ const h = this.headers(false);
186
+ delete h.accept;
187
+ return downloadFile(this.fetchFn, `${this.hub}${a.url}`, h, dir, a);
188
+ }
189
+ // ---- agent routes ----
190
+ async me() {
191
+ const r = await this.ach('GET', '/me');
192
+ const a = r?.agent ?? r;
193
+ if (r?.owner_name && !a.owner_name)
194
+ a.owner_name = r.owner_name;
195
+ return a;
196
+ }
197
+ async agents() {
198
+ const r = await this.ach('GET', '/agents');
199
+ return Array.isArray(r) ? r : arr(r?.agents ?? r?.items);
200
+ }
201
+ async sendTask(input, files) {
202
+ const r = await this.ach('POST', '/tasks', input, files);
203
+ return r?.task ?? r;
204
+ }
205
+ /** Propose a recurring task (runs after the owner approves). */
206
+ async proposeSchedule(input) {
207
+ return this.ach('POST', '/schedules', input);
208
+ }
209
+ async schedules() {
210
+ const r = await this.ach('GET', '/schedules');
211
+ return arr(r?.schedules);
212
+ }
213
+ async scheduleAction(id, action) {
214
+ return this.ach('POST', `/schedules/${enc(id)}/${action}`, {});
215
+ }
216
+ async removeSchedule(id) {
217
+ return this.ach('DELETE', `/schedules/${enc(id)}`);
218
+ }
219
+ /** New handoff body (+ optional header fields, same version). Sender's agent key only. */
220
+ async updateHandoff(taskId, body, change_note, header = {}, files) {
221
+ return this.ach('PUT', `/tasks/${enc(taskId)}/handoff`, { ...header, body, change_note }, files);
222
+ }
223
+ /** Header fields only (priority, purpose, money, key_question). Sender's agent key only. */
224
+ async updateHeader(taskId, header, change_note) {
225
+ return this.ach('PATCH', `/tasks/${enc(taskId)}`, { ...header, ...(change_note ? { change_note } : {}) });
226
+ }
227
+ async getTask(taskId) {
228
+ return normalizeBundle(await this.ach('GET', `/tasks/${enc(taskId)}`));
229
+ }
230
+ async claim(taskId, run_id, mode = 'runner') {
231
+ const r = await this.ach('POST', `/tasks/${enc(taskId)}/claim`, { run_id, ...(mode === 'interactive' ? { mode } : {}) });
232
+ const b = normalizeBundle(r);
233
+ return { ...b, run_token: r?.run_token ?? r?.token ?? '' };
234
+ }
235
+ /** Mint an answer-mode run token for a question addressed to me (§14.2). */
236
+ async answerRun(taskId, question_id, run_id) {
237
+ const r = await this.ach('POST', `/tasks/${enc(taskId)}/answer-run`, { question_id, run_id });
238
+ return { ...r, run_token: r?.run_token ?? r?.token ?? '' };
239
+ }
240
+ async postMessage(taskId, m, files) {
241
+ const r = await this.ach('POST', `/tasks/${enc(taskId)}/messages`, m, files);
242
+ // Round 5: a request addressed to someone else cc's the asking agent's owner
243
+ if (r?.message && r.cc_user && !r.message.cc_user)
244
+ r.message.cc_user = r.cc_user;
245
+ return r?.message ?? r;
246
+ }
247
+ /** With an agent key the hub requires the task's current run_id (C3); run tokens carry their own. */
248
+ async complete(taskId, result, run_id, files, handoff_version) {
249
+ return this.ach('POST', `/tasks/${enc(taskId)}/complete`, { result, ...(run_id ? { run_id } : {}), ...(handoff_version ? { handoff_version } : {}) }, files);
250
+ }
251
+ async fail(taskId, reason, run_id) {
252
+ return this.ach('POST', `/tasks/${enc(taskId)}/fail`, { reason, ...(run_id ? { run_id } : {}) });
253
+ }
254
+ /** §23: a grantee agent answers the part of a request its grant covers. */
255
+ async grantDecide(taskId, message_id, option, grant) {
256
+ return this.ach('POST', `/tasks/${enc(taskId)}/grant-decide`, { message_id, option, grant });
257
+ }
258
+ async createGrant(input) {
259
+ return this.ach('POST', '/grants', input);
260
+ }
261
+ async listGrants() {
262
+ return this.ach('GET', '/grants');
263
+ }
264
+ async revokeGrant(id) {
265
+ return this.ach('POST', `/grants/${enc(id)}/revoke`, {});
266
+ }
267
+ async cancel(taskId, reason) {
268
+ return this.ach('POST', `/tasks/${enc(taskId)}/cancel`, { reason });
269
+ }
270
+ /** The total size of a work prompt (Memory trial metrics). */
271
+ async promptSize(taskId, run_id, chars, parts = {}) {
272
+ return this.ach('POST', '/memory/prompt-size', { task: taskId, run_id, chars, parts });
273
+ }
274
+ async usage(taskId, run_id, cost_usd, estimated) {
275
+ return this.ach('POST', `/tasks/${enc(taskId)}/usage`, { run_id, cost_usd, estimated });
276
+ }
277
+ /** 409 when the run lost the task. Carries a fresh `run_token` when the old one is near expiry (C5). */
278
+ async heartbeat(taskId, run_id) {
279
+ return this.ach('POST', `/tasks/${enc(taskId)}/heartbeat`, { run_id });
280
+ }
281
+ /** Revoke the answer token(s) of a finished answer run (C4). */
282
+ async endRun(taskId, run_id) {
283
+ return this.ach('POST', `/tasks/${enc(taskId)}/end-run`, { run_id });
284
+ }
285
+ async inbox() {
286
+ const r = await this.ach('GET', '/inbox');
287
+ return {
288
+ tasks_to_me: arr(r?.tasks_to_me),
289
+ questions_to_me: flatMsgs(r?.questions_to_me),
290
+ answers_to_me: flatMsgs(r?.answers_to_me),
291
+ decisions_for_me: flatMsgs(r?.decisions_for_me),
292
+ notes_for_me: flatMsgs(r?.notes_for_me),
293
+ mentions_for_me: arr(r?.mentions_for_me),
294
+ updates_on_my_tasks: arr(r?.updates_on_my_tasks),
295
+ server_time: r?.server_time ?? new Date().toISOString(),
296
+ };
297
+ }
298
+ // ---- channels, posts, mentions, search (§16, §18) ----
299
+ async channels(all = false) {
300
+ return arr(await this.ach('GET', `/channels${all ? '?all=1' : ''}`));
301
+ }
302
+ async channelPosts(channel, o = {}) {
303
+ const q = new URLSearchParams();
304
+ if (o.since)
305
+ q.set('since', o.since);
306
+ if (o.before)
307
+ q.set('before', o.before);
308
+ if (o.limit)
309
+ q.set('limit', String(o.limit));
310
+ const qs = q.toString();
311
+ return this.ach('GET', `/channels/${enc(channelRef(channel))}/posts${qs ? `?${qs}` : ''}`);
312
+ }
313
+ async post(channel, body, files, o = {}) {
314
+ const r = await this.ach('POST', `/channels/${enc(channelRef(channel))}/posts`, { body, ...(o.announce ? { announce: true } : {}) }, files);
315
+ return r?.post ?? r;
316
+ }
317
+ async reply(postId, body, o = {}, files) {
318
+ const r = await this.ach('POST', `/posts/${enc(postId)}/replies`, { body, ...(o.evidence ? { evidence: o.evidence } : {}), ...(o.escalate ? { escalate: true } : {}) }, files);
319
+ return r?.post ?? r;
320
+ }
321
+ async thread(postId) {
322
+ return this.ach('GET', `/posts/${enc(postId)}`);
323
+ }
324
+ async search(q, o = {}) {
325
+ const p = new URLSearchParams({ q });
326
+ if (o.channel)
327
+ p.set('channel', channelRef(o.channel));
328
+ if (o.author)
329
+ p.set('author', o.author);
330
+ if (o.limit)
331
+ p.set('limit', String(o.limit));
332
+ return this.ach('GET', `/search?${p.toString()}`);
333
+ }
334
+ /** Take an @mention: a read-only "mention" run token for its thread. */
335
+ async mentionRun(mentionId, run_id) {
336
+ return this.ach('POST', `/mentions/${enc(mentionId)}/run`, { run_id });
337
+ }
338
+ async mentionEnd(mentionId, run_id) {
339
+ return this.ach('POST', `/mentions/${enc(mentionId)}/end`, { run_id });
340
+ }
341
+ async mentionUsage(mentionId, run_id, cost_usd, estimated) {
342
+ return this.ach('POST', `/mentions/${enc(mentionId)}/usage`, { run_id, cost_usd, estimated });
343
+ }
344
+ async agentProfile(name) {
345
+ return this.ach('GET', `/profiles/agents/${enc(name)}`);
346
+ }
347
+ // ---- team Memory (§21-revised; routes /api/ach/memory/*, formerly /wiki/*) ----
348
+ /** `ach remember`: a fact or lesson with its source (hub permalink or GitHub URL); toTeam promotes it to the team overview. */
349
+ async remember(text, source, o = {}) {
350
+ return this.ach('POST', '/memory/contributions', { text, source, ...(o.channel ? { channel: channelRef(o.channel) } : {}), ...(o.topic ? { topic: o.topic } : {}), ...(o.toTeam ? { to_team: true } : {}) });
351
+ }
352
+ /** `ach decide-draft`: ask the person (a decision request); their reply records the decision. */
353
+ async decideDraft(input) {
354
+ return this.ach('POST', '/memory/decisions', { ...input, ...(input.channel ? { channel: channelRef(input.channel) } : {}) });
355
+ }
356
+ async wikiIndex(channel) {
357
+ return this.ach('GET', `/memory${channel ? `?channel=${enc(channelRef(channel))}` : ''}`);
358
+ }
359
+ async wikiPage(slug) {
360
+ return this.ach('GET', `/memory/pages/${enc(slug)}`);
361
+ }
362
+ async wikiSearch(q, limit) {
363
+ return this.ach('GET', `/memory/search?q=${enc(q)}${limit ? `&limit=${limit}` : ''}`);
364
+ }
365
+ async wikiExport(channel) {
366
+ return this.ach('GET', `/memory/export${channel ? `?channel=${enc(channelRef(channel))}` : ''}`);
367
+ }
368
+ async wikiPush(input) {
369
+ return this.ach('POST', '/memory/push', input);
370
+ }
371
+ async wikiSeed() {
372
+ return this.ach('POST', '/memory/seed', { from: 'imports' });
373
+ }
374
+ /** A bounded Memory summary for an interactive session (MCP instructions, SessionStart hook). */
375
+ async memoryBrief(o = {}) {
376
+ const q = o.task ? `?task=${enc(o.task)}` : o.channel ? `?channel=${enc(channelRef(o.channel))}` : '';
377
+ return this.ach('GET', `/memory/brief${q}`);
378
+ }
379
+ // ---- user routes (decide: also an agent key speaking for its own owner, §23) ----
380
+ async decide(taskId, message_id, decision, files) {
381
+ return this.ach('POST', `/tasks/${enc(taskId)}/decide`, { message_id, decision }, files);
382
+ }
383
+ /** A human note on a task (user token; admins and the owners of either agent). */
384
+ async note(taskId, body, files) {
385
+ return this.ach('POST', `/tasks/${enc(taskId)}/note`, { body }, files);
386
+ }
387
+ async panel() {
388
+ return this.ach('GET', '/panel');
389
+ }
390
+ /** §15: a person (email optional) plus a one-time join link (24 h). No password. */
391
+ async adminCreateUser(input) {
392
+ return this.ach('POST', '/admin/users', input);
393
+ }
394
+ async adminListUsers() {
395
+ return this.ach('GET', '/admin/users');
396
+ }
397
+ /** §15: a fresh one-time login link (24 h) for a person's new device. */
398
+ async adminUserLoginLink(ref) {
399
+ return this.ach('POST', `/admin/users/${enc(ref)}/login-link`, {});
400
+ }
401
+ async adminPatchUser(ref, patch) {
402
+ return this.ach('PATCH', `/admin/users/${enc(ref)}`, patch);
403
+ }
404
+ /** §15 `ach open`: the agent key mints a 60 s single-use login link for its owner. */
405
+ async loginLink() {
406
+ return this.ach('POST', '/login-link', {});
407
+ }
408
+ /** Exchange a one-time login link token for a PocketBase user session. */
409
+ async exchangeLoginLink(token) {
410
+ return this.ach('POST', '/auth/link', { token });
411
+ }
412
+ /** P4/P5: the logged-in human's own profile (notify_webhook, password, name). */
413
+ async patchMe(patch) {
414
+ return this.ach('PATCH', '/me', patch);
415
+ }
416
+ async adminCreateAgent(input) {
417
+ return this.ach('POST', '/admin/agents', input);
418
+ }
419
+ /** `cf_token`: put the hub's shared front-door token in the invites (opt-in; default false). */
420
+ async adminRotateKey(agentId, o = {}) {
421
+ return this.ach('POST', `/admin/agents/${enc(agentId)}/rotate-key`, { cf_token: !!o.cf_token });
422
+ }
423
+ async adminPatchAgent(agentId, patch) {
424
+ return this.ach('PATCH', `/admin/agents/${enc(agentId)}`, patch);
425
+ }
426
+ /** PocketBase built-in password auth for humans. */
427
+ async userLogin(email, password) {
428
+ return this.request('POST', '/api/collections/users/auth-with-password', { identity: email, password });
429
+ }
430
+ }
431
+ /** Inbox message entries are `{message, task}`; flatten to the message (task id stays in `message.task`). */
432
+ export function flatMsgs(v) {
433
+ return arr(v).map((e) => {
434
+ if (e && e.message && typeof e.message === 'object') {
435
+ const m = { ...e.message };
436
+ if (e.task && typeof e.task === 'object')
437
+ m.task_info = e.task;
438
+ return m;
439
+ }
440
+ return e;
441
+ });
442
+ }
443
+ /** "#general" -> "general". */
444
+ export function channelRef(c) {
445
+ return String(c).trim().replace(/^#/, '');
446
+ }
447
+ function enc(s) {
448
+ return encodeURIComponent(s);
449
+ }
@@ -0,0 +1,62 @@
1
+ /** config.json: no secrets (the key, the admin session and the front-door secret live in the key store). */
2
+ export interface FileConfig {
3
+ hub?: string;
4
+ /** Who the `ach admin` session belongs to (the session itself is in the key store). */
5
+ admin_email?: string;
6
+ /** Cloudflare Access service token id (from `ach join`); its secret is in the key store; env ACH_CF_ID / ACH_CF_SECRET win. */
7
+ cf_id?: string;
8
+ /** P3: repo name -> local path for `ach run` (merged with --repo name=path). */
9
+ repos?: Record<string, string>;
10
+ /** `ach run` default: hold new tasks for INTERACTIVE_FIRST_DELAY_SEC so the owner can claim them first. */
11
+ interactive_first?: boolean;
12
+ }
13
+ export interface Config {
14
+ hub?: string;
15
+ key?: string;
16
+ runToken?: string;
17
+ taskId?: string;
18
+ agent?: string;
19
+ cfId?: string;
20
+ cfSecret?: string;
21
+ adminToken?: string;
22
+ repos?: Record<string, string>;
23
+ interactiveFirst?: boolean;
24
+ }
25
+ /** Claim delay used by `ach run --interactive-first` (or "interactive_first": true in config.json). */
26
+ export declare const INTERACTIVE_FIRST_DELAY_SEC = 600;
27
+ /**
28
+ * `ach run` claim delay in seconds: an explicit --claim-delay wins; else 600 with
29
+ * --interactive-first (flag or config); else 0 (claim at once: nobody is
30
+ * expected to work tasks interactively on this agent).
31
+ */
32
+ export declare function resolveClaimDelay(o: {
33
+ claimDelay?: number;
34
+ interactiveFirst?: boolean;
35
+ }): number;
36
+ /** Default home: $XDG_CONFIG_HOME/agentchannel, else ~/.config/agentchannel (no version in the name). */
37
+ export declare function defaultHome(env?: NodeJS.ProcessEnv): string;
38
+ /** Root dir for config and runner state. AGENTCHANNEL_HOME overrides (used by tests). */
39
+ export declare function achHome(env?: NodeJS.ProcessEnv): string;
40
+ export declare function configPath(env?: NodeJS.ProcessEnv): string;
41
+ /** Write JSON atomically with mode 0600 (dir 0700). */
42
+ export declare function writeJsonPrivate(path: string, data: unknown): void;
43
+ export declare function readJson<T>(path: string, fallback: T): T;
44
+ export declare function readFileConfig(env?: NodeJS.ProcessEnv): FileConfig;
45
+ export declare function writeFileConfig(cfg: FileConfig, env?: NodeJS.ProcessEnv): void;
46
+ /** The agent key: OS keychain, else the 0600 agent-key file. */
47
+ export declare function loadAgentKey(env?: NodeJS.ProcessEnv): string | undefined;
48
+ export declare function saveAgentKey(key: string, env?: NodeJS.ProcessEnv): 'keychain' | 'file';
49
+ /** The front-door service token secret: OS keychain, else <home>/cf-secret. */
50
+ export declare function loadCfSecret(env?: NodeJS.ProcessEnv): string | undefined;
51
+ /** Store the service token secret (or remove it: undefined). */
52
+ export declare function saveCfSecret(secret: string | undefined, env?: NodeJS.ProcessEnv): 'keychain' | 'file';
53
+ /** B1: the `ach admin` session token (keychain or <home>/admin-session; never config.json). */
54
+ export declare function loadAdminToken(env?: NodeJS.ProcessEnv): string | undefined;
55
+ /** Store the admin session; config.json keeps only whose it is. */
56
+ export declare function saveAdminToken(token: string, env?: NodeJS.ProcessEnv, email?: string): 'keychain' | 'file';
57
+ /**
58
+ * Effective config: env wins over the file. Inside a spawned agent (ACH_RUN_TOKEN
59
+ * set) nothing is read from disk or the keychain: the run token and ACH_HUB are
60
+ * the only credentials, so the agent key can never be loaded or re-pointed.
61
+ */
62
+ export declare function loadConfig(env?: NodeJS.ProcessEnv): Config;
@@ -0,0 +1,121 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ import { cfSecretPath, FileKeyStore, keyFilePath, openAdminSessionStore, openCfSecretStore, openKeyStore } from './keystore.js';
5
+ /** Claim delay used by `ach run --interactive-first` (or "interactive_first": true in config.json). */
6
+ export const INTERACTIVE_FIRST_DELAY_SEC = 600;
7
+ /**
8
+ * `ach run` claim delay in seconds: an explicit --claim-delay wins; else 600 with
9
+ * --interactive-first (flag or config); else 0 (claim at once: nobody is
10
+ * expected to work tasks interactively on this agent).
11
+ */
12
+ export function resolveClaimDelay(o) {
13
+ if (o.claimDelay !== undefined)
14
+ return o.claimDelay;
15
+ return o.interactiveFirst ? INTERACTIVE_FIRST_DELAY_SEC : 0;
16
+ }
17
+ /** Default home: $XDG_CONFIG_HOME/agentchannel, else ~/.config/agentchannel (no version in the name). */
18
+ export function defaultHome(env = process.env) {
19
+ return join(env.XDG_CONFIG_HOME || join(env.HOME || homedir(), '.config'), 'agentchannel');
20
+ }
21
+ /** Root dir for config and runner state. AGENTCHANNEL_HOME overrides (used by tests). */
22
+ export function achHome(env = process.env) {
23
+ return env.AGENTCHANNEL_HOME || defaultHome(env);
24
+ }
25
+ export function configPath(env = process.env) {
26
+ return join(achHome(env), 'config.json');
27
+ }
28
+ /** Write JSON atomically with mode 0600 (dir 0700). */
29
+ export function writeJsonPrivate(path, data) {
30
+ const dir = dirname(path);
31
+ if (!existsSync(dir))
32
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
33
+ const tmp = `${path}.${process.pid}.tmp`;
34
+ writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
35
+ chmodSync(tmp, 0o600);
36
+ renameSync(tmp, path);
37
+ chmodSync(path, 0o600);
38
+ }
39
+ export function readJson(path, fallback) {
40
+ try {
41
+ return JSON.parse(readFileSync(path, 'utf8'));
42
+ }
43
+ catch {
44
+ return fallback;
45
+ }
46
+ }
47
+ export function readFileConfig(env = process.env) {
48
+ return readJson(configPath(env), {});
49
+ }
50
+ export function writeFileConfig(cfg, env = process.env) {
51
+ writeJsonPrivate(configPath(env), cfg);
52
+ }
53
+ /** The agent key: OS keychain, else the 0600 agent-key file. */
54
+ export function loadAgentKey(env = process.env) {
55
+ const home = achHome(env);
56
+ const store = openKeyStore(home, env);
57
+ return store.get() ?? (store.kind === 'keychain' ? new FileKeyStore(keyFilePath(home)).get() : undefined);
58
+ }
59
+ export function saveAgentKey(key, env = process.env) {
60
+ const store = openKeyStore(achHome(env), env);
61
+ store.set(key);
62
+ return store.kind;
63
+ }
64
+ /** The front-door service token secret: OS keychain, else <home>/cf-secret. */
65
+ export function loadCfSecret(env = process.env) {
66
+ const home = achHome(env);
67
+ const store = openCfSecretStore(home, env);
68
+ return store.get() ?? (store.kind === 'keychain' ? new FileKeyStore(cfSecretPath(home)).get() : undefined);
69
+ }
70
+ /** Store the service token secret (or remove it: undefined). */
71
+ export function saveCfSecret(secret, env = process.env) {
72
+ const store = openCfSecretStore(achHome(env), env);
73
+ if (secret)
74
+ store.set(secret);
75
+ else
76
+ store.delete();
77
+ return store.kind;
78
+ }
79
+ /** B1: the `ach admin` session token (keychain or <home>/admin-session; never config.json). */
80
+ export function loadAdminToken(env = process.env) {
81
+ return openAdminSessionStore(achHome(env), env).get();
82
+ }
83
+ /** Store the admin session; config.json keeps only whose it is. */
84
+ export function saveAdminToken(token, env = process.env, email) {
85
+ const store = openAdminSessionStore(achHome(env), env);
86
+ store.set(token);
87
+ if (email) {
88
+ const file = readFileConfig(env);
89
+ if (file.admin_email !== email)
90
+ writeFileConfig({ ...file, admin_email: email }, env);
91
+ }
92
+ return store.kind;
93
+ }
94
+ /**
95
+ * Effective config: env wins over the file. Inside a spawned agent (ACH_RUN_TOKEN
96
+ * set) nothing is read from disk or the keychain: the run token and ACH_HUB are
97
+ * the only credentials, so the agent key can never be loaded or re-pointed.
98
+ */
99
+ export function loadConfig(env = process.env) {
100
+ const runToken = env.ACH_RUN_TOKEN || undefined;
101
+ const common = {
102
+ taskId: env.ACH_TASK_ID || undefined,
103
+ agent: env.ACH_AGENT || undefined,
104
+ cfId: env.ACH_CF_ID || undefined,
105
+ cfSecret: env.ACH_CF_SECRET || undefined,
106
+ };
107
+ if (runToken) {
108
+ return { hub: (env.ACH_HUB || '').replace(/\/+$/, '') || undefined, runToken, ...common };
109
+ }
110
+ const file = readFileConfig(env);
111
+ return {
112
+ hub: (env.ACH_HUB || file.hub || '').replace(/\/+$/, '') || undefined,
113
+ key: env.ACH_KEY || loadAgentKey(env),
114
+ ...common,
115
+ cfId: common.cfId ?? (file.cf_id || undefined),
116
+ cfSecret: common.cfSecret ?? (file.cf_id ? loadCfSecret(env) : undefined),
117
+ adminToken: loadAdminToken(env),
118
+ ...(file.repos && typeof file.repos === 'object' ? { repos: file.repos } : {}),
119
+ ...(file.interactive_first === true ? { interactiveFirst: true } : {}),
120
+ };
121
+ }