@parlor/agent-host 0.2.0 → 0.3.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 (76) hide show
  1. package/dist/acp.d.ts +21 -0
  2. package/dist/acp.d.ts.map +1 -1
  3. package/dist/acp.js +138 -12
  4. package/dist/acp.js.map +1 -1
  5. package/dist/agent-options.d.ts +22 -0
  6. package/dist/agent-options.d.ts.map +1 -0
  7. package/dist/agent-options.js +105 -0
  8. package/dist/agent-options.js.map +1 -0
  9. package/dist/agent-survey.d.ts +15 -0
  10. package/dist/agent-survey.d.ts.map +1 -0
  11. package/dist/agent-survey.js +51 -0
  12. package/dist/agent-survey.js.map +1 -0
  13. package/dist/agent-tools.d.ts +30 -0
  14. package/dist/agent-tools.d.ts.map +1 -0
  15. package/dist/agent-tools.js +71 -0
  16. package/dist/agent-tools.js.map +1 -0
  17. package/dist/codex-app-server.d.ts +7 -0
  18. package/dist/codex-app-server.d.ts.map +1 -0
  19. package/dist/codex-app-server.js +46 -0
  20. package/dist/codex-app-server.js.map +1 -0
  21. package/dist/control.d.ts +15 -0
  22. package/dist/control.d.ts.map +1 -1
  23. package/dist/control.js.map +1 -1
  24. package/dist/daemon.d.ts +10 -2
  25. package/dist/daemon.d.ts.map +1 -1
  26. package/dist/daemon.js +217 -16
  27. package/dist/daemon.js.map +1 -1
  28. package/dist/fold.d.ts.map +1 -1
  29. package/dist/fold.js +2 -0
  30. package/dist/fold.js.map +1 -1
  31. package/dist/grants.d.ts +9 -0
  32. package/dist/grants.d.ts.map +1 -1
  33. package/dist/grants.js.map +1 -1
  34. package/dist/learned.d.ts +25 -0
  35. package/dist/learned.d.ts.map +1 -0
  36. package/dist/learned.js +37 -0
  37. package/dist/learned.js.map +1 -0
  38. package/dist/link.d.ts +3 -1
  39. package/dist/link.d.ts.map +1 -1
  40. package/dist/link.js +23 -1
  41. package/dist/link.js.map +1 -1
  42. package/dist/main.js +105 -9
  43. package/dist/main.js.map +1 -1
  44. package/dist/mcp-proxy.d.ts +26 -3
  45. package/dist/mcp-proxy.d.ts.map +1 -1
  46. package/dist/mcp-proxy.js +288 -25
  47. package/dist/mcp-proxy.js.map +1 -1
  48. package/dist/pairing.d.ts +2 -0
  49. package/dist/pairing.d.ts.map +1 -1
  50. package/dist/pairing.js +25 -3
  51. package/dist/pairing.js.map +1 -1
  52. package/dist/paths.d.ts +2 -0
  53. package/dist/paths.d.ts.map +1 -1
  54. package/dist/paths.js +1 -0
  55. package/dist/paths.js.map +1 -1
  56. package/dist/policy.d.ts +12 -2
  57. package/dist/policy.d.ts.map +1 -1
  58. package/dist/policy.js +13 -3
  59. package/dist/policy.js.map +1 -1
  60. package/dist/prompter.d.ts +5 -0
  61. package/dist/prompter.d.ts.map +1 -1
  62. package/dist/prompter.js +15 -0
  63. package/dist/prompter.js.map +1 -1
  64. package/dist/spec-kit.d.ts +4 -1
  65. package/dist/spec-kit.d.ts.map +1 -1
  66. package/dist/spec-kit.js +12 -2
  67. package/dist/spec-kit.js.map +1 -1
  68. package/dist/testing/fake-agent.d.ts +12 -0
  69. package/dist/testing/fake-agent.d.ts.map +1 -1
  70. package/dist/testing/fake-agent.js +11 -1
  71. package/dist/testing/fake-agent.js.map +1 -1
  72. package/dist/turn.d.ts +7 -1
  73. package/dist/turn.d.ts.map +1 -1
  74. package/dist/turn.js +16 -3
  75. package/dist/turn.js.map +1 -1
  76. package/package.json +9 -9
package/dist/daemon.js CHANGED
@@ -3,10 +3,13 @@ import { createServer } from "node:net";
3
3
  import { basename, delimiter, dirname, join } from "node:path";
4
4
  import { HOST_PROTOCOL, } from "@parlor/chat";
5
5
  import { ActionError, asId, DEV_USER_ID } from "@parlor/core";
6
+ import { readUsage, surveyOptions } from "./agent-survey.js";
7
+ import { agentNames, registerAgentTools, unregisterAgentTools, } from "./agent-tools.js";
6
8
  import { detectAgents, loadProfiles } from "./agents.js";
7
9
  import { connectControl } from "./control.js";
8
10
  import { requestDevHostToken } from "./dev-host.js";
9
11
  import { devHostKeys } from "./grants.js";
12
+ import { learnedStore } from "./learned.js";
10
13
  import { onLines } from "./lines.js";
11
14
  import { createAppLink } from "./link.js";
12
15
  import { withInheritedLock } from "./lock.js";
@@ -19,8 +22,11 @@ import { compareVersions, RELEASE_KEY, signedRelease } from "./release.js";
19
22
  import { revokeGrant } from "./revoke.js";
20
23
  import { selfEntry } from "./self.js";
21
24
  import { installLoginItem, installRelease } from "./service.js";
25
+ import { agentEnvironment } from "./shell-path.js";
22
26
  import { runTurn } from "./turn.js";
23
27
  const CONNECT_TIMEOUT_MS = 15_000;
28
+ /** An agent token is asked for again this long before it runs out. */
29
+ const AGENT_TOKEN_MARGIN_MS = 60_000;
24
30
  const OFFLINE_REPORT_MS = 1_000;
25
31
  function incompatibleMessage(grant, welcome) {
26
32
  return welcome.reason === "host_too_old"
@@ -58,6 +64,8 @@ export class HostAlreadyRunning extends Error {
58
64
  super("Parlor Host is already running on this computer.");
59
65
  }
60
66
  }
67
+ /** Codex's own CLI throttles its rate-limit reports on the same interval. */
68
+ const USAGE_READ_FLOOR_MS = 30_000;
61
69
  export function createHostDaemon(deps) {
62
70
  const { paths } = deps;
63
71
  const links = new Map();
@@ -66,6 +74,7 @@ export function createHostDaemon(deps) {
66
74
  const platform = platformName();
67
75
  let profiles = [];
68
76
  let agents = [];
77
+ const learned = learnedStore(paths.learned);
69
78
  let control;
70
79
  let startup = Promise.resolve();
71
80
  const clients = new Set();
@@ -83,14 +92,75 @@ export function createHostDaemon(deps) {
83
92
  return undefined;
84
93
  }
85
94
  };
95
+ /** What an agent said about itself, told to every linked app when it changes what they show. */
96
+ const learn = (agentId, news) => {
97
+ void learned
98
+ .learn(agentId, news)
99
+ .then((changed) => {
100
+ if (!changed)
101
+ return;
102
+ for (const linked of links.values())
103
+ void report(linked).catch(() => undefined);
104
+ })
105
+ .catch(() => undefined);
106
+ };
107
+ /** Ends the reads below on stop or handover, so nothing outlives the daemon. */
108
+ const stopping = new AbortController();
109
+ const usageReadAt = new Map();
110
+ /** The allowance from the agent's own CLI where ACP carries none, at most every 30s per agent. */
111
+ const refreshUsage = async (agentId) => {
112
+ const profile = profiles.find((candidate) => candidate.id === agentId);
113
+ const now = Date.now();
114
+ if (!profile || stopping.signal.aborted)
115
+ return;
116
+ if (now - (usageReadAt.get(agentId) ?? 0) < USAGE_READ_FLOOR_MS)
117
+ return;
118
+ usageReadAt.set(agentId, now);
119
+ const usage = await readUsage(profile, await agentEnvironment(), now).catch(() => undefined);
120
+ if (usage && !stopping.signal.aborted)
121
+ learn(agentId, { usage });
122
+ };
123
+ let surveyed = false;
124
+ /**
125
+ * Options for every agent that has not run a turn yet, so the sidebar can offer a model first.
126
+ * Once per run, when an app first links: a computer no app uses never starts its agents.
127
+ */
128
+ const surveyAgents = async () => {
129
+ if (surveyed)
130
+ return;
131
+ surveyed = true;
132
+ const env = await agentEnvironment();
133
+ for (const agent of agents) {
134
+ const profile = profiles.find((candidate) => candidate.id === agent.id);
135
+ if (!profile || stopping.signal.aborted)
136
+ continue;
137
+ void refreshUsage(agent.id);
138
+ if (learned.knows(agent.id))
139
+ continue;
140
+ const options = await surveyOptions(profile, deps.launcher, env, stopping.signal);
141
+ if (stopping.signal.aborted)
142
+ return;
143
+ logEvent("agent_surveyed", { agentId: agent.id, options: options?.length ?? 0 });
144
+ if (options && options.length > 0)
145
+ learn(agent.id, { options });
146
+ }
147
+ };
86
148
  const running = new Set();
87
149
  let handedOver = false;
88
150
  let finishing = false;
89
151
  let finishHandover = () => undefined;
90
152
  const drained = new Promise((resolve) => (finishHandover = resolve));
91
- const run = (state, claimed) => {
153
+ /** A turn runs once here, even when a pushed turn its app took back is claimed again. */
154
+ const runningTurns = new Set();
155
+ const run = (state, claimed, how = "claimed") => {
92
156
  const { link } = state;
93
- logEvent("turn_claimed", { appOrigin: link.grant.appOrigin, turnId: claimed.turnId });
157
+ if (runningTurns.has(claimed.turnId))
158
+ return;
159
+ runningTurns.add(claimed.turnId);
160
+ logEvent(how === "pushed" ? "turn_pushed" : "turn_claimed", {
161
+ appOrigin: link.grant.appOrigin,
162
+ turnId: claimed.turnId,
163
+ });
94
164
  state.turns += 1;
95
165
  const turn = (link.grant.dev ? Promise.resolve(DEV_POLICY) : loadPolicy(paths.policy, deps.policy))
96
166
  .then((policy) => runTurn(claimed, {
@@ -113,6 +183,7 @@ export function createHostDaemon(deps) {
113
183
  policy,
114
184
  prompter: deps.prompter,
115
185
  now: Date.now,
186
+ learned: learn,
116
187
  pairing: {
117
188
  secret: async () => (await deps.grants.get(link.grant.appOrigin))?.pairingSecret,
118
189
  forget: async () => {
@@ -126,6 +197,8 @@ export function createHostDaemon(deps) {
126
197
  }))
127
198
  .then((outcome) => logEvent("turn_finished", { turnId: claimed.turnId, outcome }), (error) => logEvent("turn_finished", { turnId: claimed.turnId, error: String(error) }))
128
199
  .finally(() => {
200
+ runningTurns.delete(claimed.turnId);
201
+ void refreshUsage(claimed.agent.agentId);
129
202
  state.turns -= 1;
130
203
  running.delete(turn);
131
204
  settleHandover();
@@ -159,7 +232,79 @@ export function createHostDaemon(deps) {
159
232
  if (state)
160
233
  logEvent("link_closed", { appOrigin: origin, reason });
161
234
  };
235
+ const toolsDeps = () => ({
236
+ profiles: profiles.filter((profile) => agents.some((agent) => agent.id === profile.id)),
237
+ hostCommand: join(paths.root, "bin", "parlor-host"),
238
+ env: agentEnvironment,
239
+ });
240
+ const toolsOf = (grant) => {
241
+ const tools = grant?.agentTools;
242
+ if (!tools?.on || !tools.name || !tools.agents?.length)
243
+ return undefined;
244
+ return {
245
+ name: tools.name,
246
+ agents: agentNames(profiles, { name: tools.name, agents: tools.agents }),
247
+ };
248
+ };
249
+ /** Registers the app with the agents here once the user chose it and it is not yet. */
250
+ const ensureAgentTools = async (grant) => {
251
+ if (!grant.agentTools?.on || grant.dev || grant.agentTools.agents)
252
+ return grant;
253
+ const registered = await registerAgentTools(toolsDeps(), grant);
254
+ const next = { ...grant, agentTools: { on: true, ...registered } };
255
+ await deps.grants.save(next);
256
+ return next;
257
+ };
258
+ const removeAgentTools = async (grant) => {
259
+ const tools = grant?.agentTools;
260
+ if (!tools?.name || !tools.agents?.length)
261
+ return;
262
+ await unregisterAgentTools(toolsDeps(), { name: tools.name, agents: tools.agents });
263
+ };
264
+ const agentTokens = new Map();
265
+ /** The grant as last saved, which a link opened before a change still holds the old copy of. */
266
+ const latest = new Map();
267
+ deps.proxy.serveOutside((appOrigin) => {
268
+ const state = links.get(appOrigin);
269
+ if (!state || state.link.grant.dev)
270
+ return undefined;
271
+ const grant = latest.get(appOrigin) ?? state.link.grant;
272
+ if (!grant.agentTools?.on || !grant.agentTools.name)
273
+ return undefined;
274
+ return {
275
+ appOrigin,
276
+ appName: grant.appName,
277
+ name: grant.agentTools.name,
278
+ account: grant.account.email ?? grant.account.name,
279
+ token: async (agent) => {
280
+ const key = `${grant.grantId}\n${agent}`;
281
+ const held = agentTokens.get(key);
282
+ if (held && held.expiresAt - AGENT_TOKEN_MARGIN_MS > Date.now())
283
+ return held.token;
284
+ const fresh = await state.link.api.calls.call("hostAgentToken", {
285
+ agent: `${agent} on ${name}`.slice(0, 120),
286
+ });
287
+ agentTokens.set(key, fresh);
288
+ return fresh.token;
289
+ },
290
+ askHere: async (question) => (await deps.prompter.answerHere({
291
+ appName: grant.appName,
292
+ agent: "Parlor Host",
293
+ question,
294
+ choices: [
295
+ { id: "allow", label: "Allow" },
296
+ { id: "deny", label: "Don't allow" },
297
+ ],
298
+ })) === "allow",
299
+ };
300
+ });
162
301
  const forget = async (grant) => {
302
+ await removeAgentTools((await deps.grants.get(grant.appOrigin)) ?? grant);
303
+ latest.delete(grant.appOrigin);
304
+ for (const key of agentTokens.keys()) {
305
+ if (key.startsWith(`${grant.grantId}\n`))
306
+ agentTokens.delete(key);
307
+ }
163
308
  devTokens.delete(grant.appOrigin);
164
309
  await deps.grants.remove(grant.appOrigin);
165
310
  await deps.keys.remove(grant.appOrigin);
@@ -167,7 +312,9 @@ export function createHostDaemon(deps) {
167
312
  await deps.pool.forget(grant);
168
313
  };
169
314
  const open = (grant, keys) => {
315
+ void surveyAgents().catch(() => undefined);
170
316
  const origin = grant.appOrigin;
317
+ latest.set(origin, grant);
171
318
  let state;
172
319
  const link = createAppLink(grant, {
173
320
  keys,
@@ -176,14 +323,25 @@ export function createHostDaemon(deps) {
176
323
  platform,
177
324
  fetch: (input, init) => fetch(input, init),
178
325
  onWake: () => wake(state),
326
+ onPushed: (_link, turn) => {
327
+ // Tells the app this computer has the turn, while it already starts on it.
328
+ void link.api.calls
329
+ .call("hostAppend", { turnId: turn.turnId, items: [] })
330
+ .catch(() => undefined);
331
+ run(state, turn, "pushed");
332
+ },
179
333
  busy: () => state.turns > 0,
180
334
  onRevoked: () => {
181
335
  if (links.get(origin) !== state)
182
336
  return;
183
337
  if (grant.dev)
184
338
  void reattach(grant);
185
- else
186
- void closeLink(origin, "revoked").then(() => forget(grant));
339
+ else {
340
+ const removedFrom = toolsOf(latest.get(origin) ?? grant)?.agents;
341
+ void closeLink(origin, "revoked")
342
+ .then(() => forget(grant))
343
+ .then(() => deps.prompter.disconnected?.({ appName: grant.appName, removedFrom }));
344
+ }
187
345
  },
188
346
  onIncompatible: (refused, welcome) => {
189
347
  state.incompatible = incompatibleMessage(refused, welcome);
@@ -217,10 +375,19 @@ export function createHostDaemon(deps) {
217
375
  }
218
376
  return { devices: pinned.map((device) => device.deviceId), presenceDue };
219
377
  };
378
+ /** The agents with what they said about themselves, and the name they use the app by outside. */
379
+ const describeFor = (origin) => {
380
+ const tools = latest.get(origin)?.agentTools;
381
+ return learned
382
+ .describe(agents, Date.now())
383
+ .map((agent) => tools?.on && tools.name && tools.agents?.includes(agent.id)
384
+ ? { ...agent, outsideName: tools.name }
385
+ : agent);
386
+ };
220
387
  const report = async (state) => {
221
388
  const reported = state.link.api.calls.call("hostReport", {
222
389
  online: true,
223
- agents,
390
+ agents: describeFor(state.link.grant.appOrigin),
224
391
  ...(await pinnedDevices(state)),
225
392
  });
226
393
  const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error("The app did not answer in time.")), CONNECT_TIMEOUT_MS));
@@ -306,6 +473,7 @@ export function createHostDaemon(deps) {
306
473
  await deps.proxy.listen(paths.proxy);
307
474
  profiles = await loadProfiles(paths.agents);
308
475
  agents = await detectAgents(profiles);
476
+ await learned.load();
309
477
  finishStartup();
310
478
  }
311
479
  catch (error) {
@@ -323,25 +491,46 @@ export function createHostDaemon(deps) {
323
491
  }
324
492
  },
325
493
  async connect(appOrigin) {
326
- const grant = await deps.grants.get(appOrigin);
327
- if (!grant || grant.declined) {
494
+ const saved = await deps.grants.get(appOrigin);
495
+ if (!saved || saved.declined) {
328
496
  throw new Error(`This computer is not connected to ${appOrigin}.`);
329
497
  }
498
+ const grant = await ensureAgentTools(saved);
330
499
  const current = links.get(appOrigin);
331
500
  if (current?.link.grant.grantId === grant.grantId) {
501
+ latest.set(appOrigin, grant);
332
502
  await report(current);
333
- return;
503
+ return toolsOf(grant);
334
504
  }
335
505
  await closeLink(appOrigin, "replaced");
336
506
  if (current)
337
507
  await deps.pool.forget(current.link.grant);
508
+ latest.set(appOrigin, grant);
338
509
  await report(open(grant, deps.keys));
510
+ return toolsOf(grant);
511
+ },
512
+ async agentTools(appOrigin, on) {
513
+ const saved = await deps.grants.get(appOrigin);
514
+ if (!saved || saved.declined || saved.dev) {
515
+ throw new Error(`This computer is not connected to ${appOrigin}.`);
516
+ }
517
+ if (!on)
518
+ await removeAgentTools(saved);
519
+ const grant = await ensureAgentTools({ ...saved, agentTools: { on } });
520
+ if (!on)
521
+ await deps.grants.save(grant);
522
+ latest.set(appOrigin, grant);
523
+ const state = links.get(appOrigin);
524
+ if (state)
525
+ await report(state).catch(() => undefined);
526
+ return toolsOf(grant);
339
527
  },
340
528
  async disconnect(appOrigin) {
341
529
  const state = links.get(appOrigin);
342
- const grant = state?.link.grant ?? (await deps.grants.get(appOrigin));
530
+ const grant = (await deps.grants.get(appOrigin)) ?? state?.link.grant;
343
531
  if (!grant)
344
532
  throw new Error(`This computer is not connected to ${appOrigin}.`);
533
+ const removed = toolsOf(grant);
345
534
  if (state) {
346
535
  await state.link.stop({ revoke: true });
347
536
  }
@@ -354,11 +543,13 @@ export function createHostDaemon(deps) {
354
543
  }
355
544
  await closeLink(appOrigin, "disconnected");
356
545
  await forget(grant);
546
+ return removed;
357
547
  },
358
548
  async handover() {
359
549
  if (handedOver)
360
550
  return;
361
551
  handedOver = true;
552
+ stopping.abort();
362
553
  for (const state of links.values())
363
554
  state.link.drain();
364
555
  await Promise.all([...links.values()].map((state) => state.claiming));
@@ -369,9 +560,13 @@ export function createHostDaemon(deps) {
369
560
  logEvent("handover_started", { version: deps.release.version });
370
561
  },
371
562
  async stop() {
563
+ stopping.abort();
372
564
  if (!handedOver) {
373
565
  await Promise.all([...links.values()].map((state) => Promise.race([
374
- state.link.api.calls.call("hostReport", { online: false, agents }),
566
+ state.link.api.calls.call("hostReport", {
567
+ online: false,
568
+ agents: describeFor(state.link.grant.appOrigin),
569
+ }),
375
570
  new Promise((resolve) => setTimeout(resolve, OFFLINE_REPORT_MS)),
376
571
  ]).catch(() => undefined)));
377
572
  }
@@ -499,12 +694,18 @@ export function createHostDaemon(deps) {
499
694
  }
500
695
  case "confirm_local_pairing":
501
696
  return confirmPairing(request);
502
- case "connect":
503
- await daemon.connect(request.appOrigin);
504
- return { ok: true };
505
- case "disconnect":
506
- await daemon.disconnect(request.appOrigin);
507
- return { ok: true };
697
+ case "connect": {
698
+ const agentTools = await daemon.connect(request.appOrigin);
699
+ return { ok: true, ...(agentTools ? { agentTools } : {}) };
700
+ }
701
+ case "disconnect": {
702
+ const agentTools = await daemon.disconnect(request.appOrigin);
703
+ return { ok: true, ...(agentTools ? { agentTools } : {}) };
704
+ }
705
+ case "agent_tools": {
706
+ const agentTools = await daemon.agentTools(request.appOrigin, request.on);
707
+ return { ok: true, ...(agentTools ? { agentTools } : {}) };
708
+ }
508
709
  case "dev_attach":
509
710
  await attach(request.appOrigin, request.hostToken);
510
711
  return { ok: true };