agl 40.5.1 → 41.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 (137) hide show
  1. package/changelog.md +43 -0
  2. package/dist_serve/bundle.js +3514 -3357
  3. package/dist_serve/bundle.js.map +2 -2
  4. package/dist_serve/bundle.js.third-party-notices.json +58 -32
  5. package/dist_serve/bundle.js.third-party-notices.txt +108 -33
  6. package/dist_ts/00_commitinfo_data.js +1 -1
  7. package/dist_ts/aglapi/aglapisurface.document.js +64 -37
  8. package/dist_ts/classes.aglhome.d.ts +5 -0
  9. package/dist_ts/classes.aglhome.js +2 -1
  10. package/dist_ts/classes.authstore.js +5 -1
  11. package/dist_ts/classes.controller.d.ts +8 -0
  12. package/dist_ts/classes.controller.js +119 -22
  13. package/dist_ts/classes.controllereventdelivery.d.ts +72 -0
  14. package/dist_ts/classes.controllereventdelivery.js +233 -0
  15. package/dist_ts/classes.flexmodels.public.js +4 -1
  16. package/dist_ts/classes.flexprojectionreader.d.ts +6 -0
  17. package/dist_ts/classes.flexprojectionreader.js +29 -5
  18. package/dist_ts/classes.flexservice.d.ts +2 -1
  19. package/dist_ts/classes.flexservice.js +34 -6
  20. package/dist_ts/classes.flexstore.d.ts +41 -4
  21. package/dist_ts/classes.flexstore.js +266 -203
  22. package/dist_ts/classes.inferencegateway.d.ts +35 -1
  23. package/dist_ts/classes.inferencegateway.js +86 -26
  24. package/dist_ts/classes.inferenceservice.d.ts +4 -2
  25. package/dist_ts/classes.inferenceservice.js +5 -4
  26. package/dist_ts/classes.proxycontrol.d.ts +58 -4
  27. package/dist_ts/classes.proxycontrol.js +23 -44
  28. package/dist_ts/classes.proxycontrolclient.d.ts +15 -5
  29. package/dist_ts/classes.proxycontrolclient.js +67 -17
  30. package/dist_ts/classes.proxydaemon.d.ts +22 -3
  31. package/dist_ts/classes.proxydaemon.js +73 -12
  32. package/dist_ts/classes.proxyleaseholder.d.ts +43 -14
  33. package/dist_ts/classes.proxyleaseholder.js +66 -22
  34. package/dist_ts/classes.proxystore.d.ts +9 -0
  35. package/dist_ts/classes.proxystore.js +15 -7
  36. package/dist_ts/classes.ticketnotifier.js +4 -2
  37. package/dist_ts/classes.ticketstore.d.ts +50 -2
  38. package/dist_ts/classes.ticketstore.js +195 -78
  39. package/dist_ts/classes.workercallerbindings.d.ts +66 -0
  40. package/dist_ts/classes.workercallerbindings.js +211 -0
  41. package/dist_ts/classes.workercallers.d.ts +63 -2
  42. package/dist_ts/classes.workercallers.js +430 -65
  43. package/dist_ts/driver/classes.drivereventrelay.d.ts +13 -1
  44. package/dist_ts/driver/classes.drivereventrelay.js +99 -6
  45. package/dist_ts/driver/functions.driverinstructions.d.ts +1 -1
  46. package/dist_ts/driver/functions.driverinstructions.js +1 -1
  47. package/dist_ts/driver/functions.drivertools.d.ts +11 -2
  48. package/dist_ts/driver/functions.drivertools.js +19 -9
  49. package/dist_ts/fleet/classes.boardpump.d.ts +8 -2
  50. package/dist_ts/fleet/classes.boardpump.js +38 -20
  51. package/dist_ts/fleet/classes.ticketeffects.d.ts +17 -2
  52. package/dist_ts/fleet/classes.ticketeffects.js +16 -10
  53. package/dist_ts/fleet/classes.workerlaunches.d.ts +12 -5
  54. package/dist_ts/fleet/classes.workerlaunches.js +25 -19
  55. package/dist_ts/fleet/classes.workers.d.ts +21 -0
  56. package/dist_ts/fleet/classes.workers.js +110 -17
  57. package/dist_ts/fleet/functions.workervalidation.js +2 -2
  58. package/dist_ts/functions.flexprivatesnapshot.d.ts +102 -0
  59. package/dist_ts/functions.flexprivatesnapshot.js +461 -0
  60. package/dist_ts/functions.inferencecli.d.ts +3 -2
  61. package/dist_ts/functions.inferencecli.js +67 -5
  62. package/dist_ts/functions.inferencecliarguments.d.ts +6 -2
  63. package/dist_ts/functions.inferencecliarguments.js +29 -2
  64. package/dist_ts/functions.inferencelaunch.d.ts +24 -0
  65. package/dist_ts/functions.inferencelaunch.js +42 -2
  66. package/dist_ts/functions.mcptickettools.d.ts +3 -0
  67. package/dist_ts/functions.mcptickettools.js +2 -2
  68. package/dist_ts/functions.proxycontract.d.ts +11 -0
  69. package/dist_ts/functions.proxycontract.js +12 -1
  70. package/dist_ts/functions.proxysocket.d.ts +6 -0
  71. package/dist_ts/functions.proxysocket.js +46 -0
  72. package/dist_ts/functions.ticketcli.d.ts +1 -1
  73. package/dist_ts/functions.ticketcli.js +8 -4
  74. package/dist_ts/functions.ticketvalidation.d.ts +3 -0
  75. package/dist_ts/functions.ticketvalidation.js +7 -4
  76. package/dist_ts/functions.workerprocessproof.d.ts +6 -0
  77. package/dist_ts/functions.workerprocessproof.js +28 -4
  78. package/dist_ts/interfaces.flexipc.d.ts +21 -2
  79. package/dist_ts/interfaces.flexipc.js +33 -16
  80. package/dist_ts_interfaces/fleetworkers.d.ts +6 -0
  81. package/dist_ts_interfaces/interfaces.d.ts +1 -1
  82. package/dist_ts_interfaces/interfaces.js +1 -1
  83. package/dist_ts_interfaces/requests.tickets.d.ts +8 -12
  84. package/dist_ts_interfaces/tickets.d.ts +37 -9
  85. package/dist_ts_interfaces/tickets.js +7 -1
  86. package/dist_ts_interfaces/workercallers.d.ts +43 -4
  87. package/package.json +7 -7
  88. package/readme.md +156 -23
  89. package/ts/00_commitinfo_data.ts +1 -1
  90. package/ts/aglapi/aglapisurface.document.ts +63 -36
  91. package/ts/classes.aglhome.ts +6 -0
  92. package/ts/classes.authstore.ts +4 -0
  93. package/ts/classes.controller.ts +141 -32
  94. package/ts/classes.controllereventdelivery.ts +289 -0
  95. package/ts/classes.flexmodels.public.ts +10 -0
  96. package/ts/classes.flexprojectionreader.ts +30 -3
  97. package/ts/classes.flexservice.ts +33 -5
  98. package/ts/classes.flexstore.ts +381 -246
  99. package/ts/classes.inferencegateway.ts +104 -23
  100. package/ts/classes.inferenceservice.ts +6 -3
  101. package/ts/classes.proxycontrol.ts +76 -39
  102. package/ts/classes.proxycontrolclient.ts +72 -20
  103. package/ts/classes.proxydaemon.ts +85 -15
  104. package/ts/classes.proxyleaseholder.ts +84 -28
  105. package/ts/classes.proxystore.ts +16 -5
  106. package/ts/classes.ticketnotifier.ts +3 -1
  107. package/ts/classes.ticketstore.ts +201 -75
  108. package/ts/classes.workercallerbindings.ts +147 -0
  109. package/ts/classes.workercallers.ts +410 -59
  110. package/ts/driver/classes.drivereventrelay.ts +89 -4
  111. package/ts/driver/functions.driverinstructions.ts +1 -1
  112. package/ts/driver/functions.drivertools.ts +22 -8
  113. package/ts/fleet/classes.boardpump.ts +51 -19
  114. package/ts/fleet/classes.ticketeffects.ts +25 -11
  115. package/ts/fleet/classes.workerlaunches.ts +23 -15
  116. package/ts/fleet/classes.workers.ts +79 -17
  117. package/ts/fleet/functions.workervalidation.ts +1 -1
  118. package/ts/functions.flexprivatesnapshot.ts +601 -0
  119. package/ts/functions.inferencecli.ts +61 -7
  120. package/ts/functions.inferencecliarguments.ts +32 -1
  121. package/ts/functions.inferencelaunch.ts +49 -2
  122. package/ts/functions.mcptickettools.ts +1 -1
  123. package/ts/functions.proxycontract.ts +12 -0
  124. package/ts/functions.proxysocket.ts +39 -0
  125. package/ts/functions.ticketcli.ts +7 -3
  126. package/ts/functions.ticketvalidation.ts +7 -3
  127. package/ts/functions.workerprocessproof.ts +27 -4
  128. package/ts/interfaces.flexipc.ts +50 -21
  129. package/ts_interfaces/fleetworkers.ts +6 -0
  130. package/ts_interfaces/interfaces.ts +1 -1
  131. package/ts_interfaces/requests.tickets.ts +9 -6
  132. package/ts_interfaces/tickets.ts +32 -1
  133. package/ts_interfaces/workercallers.ts +32 -4
  134. package/ts_web/00_commitinfo_data.ts +1 -1
  135. package/ts_web/elements.harnesscontrollerapp.ts +19 -8
  136. package/ts_web/elements.ticketboard.ts +16 -4
  137. package/ts_web/settings.general.ts +10 -4
@@ -3,13 +3,13 @@ import { commitinfo } from './00_commitinfo_data.js';
3
3
  import { ensureControllerDataRoot, preflightAGLHomeMigration } from './functions.controllerdataroot.js';
4
4
  import { resolveAGLHomePaths } from './classes.aglhome.js';
5
5
  import { resolveProductionAuthoritySocketPaths } from './classes.authorityclient.js';
6
- import { inferenceHarnessLaunch } from './functions.inferencelaunch.js';
6
+ import { confinedDockerLaunch, inferenceHarnessLaunch } from './functions.inferencelaunch.js';
7
7
  import type { IAglProxyStatus } from './classes.proxycontrol.js';
8
8
  import { AglProxyControlClient, AglProxyUnavailableError } from './classes.proxycontrolclient.js';
9
- import { AglProxyLeaseHolder } from './classes.proxyleaseholder.js';
9
+ import { AglProxyConfinedHolder, AglProxyLeaseHolder, type AglProxyLeaseLostError } from './classes.proxyleaseholder.js';
10
10
  import { AglProxyDaemon } from './classes.proxydaemon.js';
11
11
  import { AglProxyService, resolveAglExecutable } from './classes.proxyservice.js';
12
- import { aglProxyUnitName } from './functions.proxycontract.js';
12
+ import { aglProxyConfinedMountPoint, aglProxyUnitName } from './functions.proxycontract.js';
13
13
  import { inferenceCliHelp, parseInferenceCliArguments, type IInferenceCliArguments } from './functions.inferencecliarguments.js';
14
14
  import { runProxyCredentialCommand } from './functions.proxycredentialcli.js';
15
15
  import { currentProxyCredentialHelper, writeProxyCredentialHelper } from './functions.proxycredentialhelper.js';
@@ -19,11 +19,12 @@ type TAccountPlacementKey = 'lifecycle' | 'lapsesAt' | 'placementScore' | 'place
19
19
 
20
20
  /**
21
21
  * The status a running proxy answers with: one started from an AGL before 38.3.0 reports no accounts and no usage
22
- * observations, and one before 38.4.0 no lifecycle, placement or placement journal.
22
+ * observations, one before 38.4.0 no lifecycle, placement or placement journal, and one before confined runs neither
23
+ * its confined listener nor its runs.
23
24
  */
24
25
  export type TReportedProxyStatus = Omit<IAglProxyStatus, 'accounts' | 'usageObservations' | 'nextPlacement'
25
- | 'recentPlacements'>
26
- & Partial<Pick<IAglProxyStatus, 'usageObservations' | 'nextPlacement' | 'recentPlacements'>>
26
+ | 'recentPlacements' | 'confinedSocketPath' | 'confinedRuns'>
27
+ & Partial<Pick<IAglProxyStatus, 'usageObservations' | 'nextPlacement' | 'recentPlacements' | 'confinedSocketPath' | 'confinedRuns'>>
27
28
  & { accounts?: Array<Omit<IAglProxyStatus['accounts'][number], TAccountPlacementKey>
28
29
  & Partial<Pick<IAglProxyStatus['accounts'][number], TAccountPlacementKey>>> };
29
30
 
@@ -69,6 +70,14 @@ export const formatProxyStatus = (unitArg: string, statusArg: TReportedProxyStat
69
70
  + `${client.generation}${client.delivery === 'file' ? ' (kept in a file)' : ''}, ${client.pending} in flight, `
70
71
  + `last used ${client.lastUsedAt ?? 'never'}`);
71
72
  }
73
+ if (statusArg.confinedSocketPath !== undefined) {
74
+ lines.push(`Confined listener: ${statusArg.confinedSocketPath ?? 'none'}`);
75
+ for (const run of statusArg.confinedRuns ?? []) {
76
+ lines.push(`Confined run ${run.id}: issued ${run.createdAt}`
77
+ + `${run.held ? '' : ', waiting for its launcher to take it back after a proxy restart'}`
78
+ + ` (end it with \`agl proxy confined end ${run.id}\`)`);
79
+ }
80
+ }
72
81
  if (!statusArg.accounts) {
73
82
  // The proxy runs the older AGL it was started from until it restarts, as after an upgrade it was not restarted by.
74
83
  lines.push(`Accounts: not reported, as the proxy runs AGL ${statusArg.version} and this command AGL ${commitinfo.version}. `
@@ -130,7 +139,7 @@ const serve = (portArg: number): Promise<void> => withSignals(async (signalArg)
130
139
  const credentialHelper = await currentProxyCredentialHelper(paths.root,
131
140
  () => resolveAglExecutable(process.env, plugins.os.homedir()));
132
141
  const daemon = new AglProxyDaemon({ socketPath: paths.proxySocket, dataDirectory: plugins.path.join(paths.proxy, 'nosqldb'),
133
- credentialDirectory: paths.proxyCredentials,
142
+ credentialDirectory: paths.proxyCredentials, confinedSocketPath: paths.proxyConfinedSocket,
134
143
  authorityPaths: resolveProductionAuthoritySocketPaths(), port: portArg });
135
144
  const stopping = () => { void daemon.stop().catch(() => undefined); };
136
145
  signalArg.addEventListener('abort', stopping, { once: true });
@@ -196,6 +205,45 @@ const run = (optionsArg: IInferenceCliArguments): Promise<void> => withSignals(a
196
205
  if (lost) throw new Error(`${lost.message} Run \`agl proxy run ${optionsArg.harness}\` again to continue.`);
197
206
  });
198
207
 
208
+ /**
209
+ * Runs a confined run's container (see `confinedDockerLaunch`) on a credential of its own, which ends when the container
210
+ * exits. A proxy that restarts meanwhile hands the credential back; one the proxy no longer hands back, or the operator
211
+ * ended, stops the container (`docker run` passes the signal on to it), and the run ends with the reason.
212
+ */
213
+ const confinedRun = (optionsArg: IInferenceCliArguments): Promise<void> => withSignals(async (signalArg) => {
214
+ await preflightAGLHomeMigration();
215
+ const paths = resolveAGLHomePaths();
216
+ const run = await AglProxyConfinedHolder.take({ socketPath: paths.proxySocket }, AbortSignal.any([signalArg, AbortSignal.timeout(90_000)]));
217
+ let lost: AglProxyLeaseLostError | undefined;
218
+ try {
219
+ const launch = confinedDockerLaunch({ args: optionsArg.args, socketPath: run.confinedSocketPath, baseUrl: run.baseUrl,
220
+ token: run.token, environment: process.env });
221
+ process.stderr.write(`AGL proxy confined run ${run.id}: ${plugins.path.dirname(run.confinedSocketPath)} is mounted read-only `
222
+ + `at ${aglProxyConfinedMountPoint}; end it at once with \`agl proxy confined end ${run.id}\`.\n`);
223
+ signalArg.throwIfAborted();
224
+ const child = plugins.childProcess.spawn(launch.command, launch.args, { env: launch.environment, stdio: 'inherit', shell: false });
225
+ let killTimer: ReturnType<typeof setTimeout> | undefined;
226
+ const terminate = () => {
227
+ child.kill('SIGTERM');
228
+ killTimer ??= setTimeout(() => child.kill('SIGKILL'), 10_000);
229
+ killTimer.unref();
230
+ };
231
+ signalArg.addEventListener('abort', terminate, { once: true });
232
+ void run.lost.then((errorArg) => { lost = errorArg; terminate(); });
233
+ try {
234
+ if (signalArg.aborted) terminate();
235
+ process.exitCode = await new Promise<number>((resolve, reject) => {
236
+ child.once('error', reject);
237
+ child.once('exit', (codeArg) => resolve(codeArg ?? 1));
238
+ });
239
+ } finally {
240
+ signalArg.removeEventListener('abort', terminate);
241
+ if (killTimer) clearTimeout(killTimer);
242
+ }
243
+ } finally { run.close(); }
244
+ if (lost) throw new Error(`${lost.message} The container was stopped; start the run again to continue.`);
245
+ });
246
+
199
247
  const unitState = async (serviceArg: AglProxyService): Promise<string> => {
200
248
  try {
201
249
  const state = await serviceArg.inspect();
@@ -212,8 +260,14 @@ export const runInferenceCli = async (argvArg: readonly string[]): Promise<void>
212
260
  const options = parseInferenceCliArguments(argvArg);
213
261
  if (options.command === 'serve') { await preflightAGLHomeMigration(); await serve(options.port); return; }
214
262
  if (options.command === 'run') { await run(options); return; }
263
+ if (options.command === 'confined' && options.confinedAction === 'run') { await confinedRun(options); return; }
215
264
  const paths = resolveAGLHomePaths();
216
265
  const client = new AglProxyControlClient(paths.proxySocket);
266
+ if (options.command === 'confined') {
267
+ await client.endConfined(options.confinedId!, AbortSignal.timeout(10_000));
268
+ process.stdout.write(`Ended confined run ${options.confinedId}: the proxy refuses its credential from now on.\n`);
269
+ return;
270
+ }
217
271
  if (options.command === 'credential') {
218
272
  await runProxyCredentialCommand(options, (textArg) => process.stdout.write(textArg));
219
273
  return;
@@ -2,6 +2,8 @@ import * as path from 'node:path';
2
2
  import type { IInferenceRoutingPolicy } from './classes.inferencerouter.js';
3
3
  import type { TInferenceHarness } from './functions.inferencelaunch.js';
4
4
  import {
5
+ aglProxyConfinedIdPattern,
6
+ aglProxyConfinedMountPoint,
5
7
  aglProxyDefaultPort,
6
8
  aglProxyUnitName,
7
9
  defaultProxyClientProvider,
@@ -24,6 +26,8 @@ export const inferenceCliHelp = `Usage:
24
26
  agl proxy status [--json]
25
27
  agl proxy credential <client> [--provider openai|anthropic] [--delivery file|none] [--write <path>] [--rotate] | --version
26
28
  agl proxy run codex|claude|opencode [--provider openai|anthropic] [--account <id>] [--plan <name>] [--model <id>] -- [harness arguments]
29
+ agl proxy confined run -- docker run [docker options] <image> [command]
30
+ agl proxy confined end <run id>
27
31
 
28
32
  The proxy runs as the ${aglProxyUnitName} user unit (serve runs it in a terminal instead).
29
33
  Every client (codex, claude, opencode, or a name of your own) has one credential, sealed by the proxy.
@@ -35,6 +39,13 @@ Each client is served from one pool: ChatGPT accounts (openai) or Claude account
35
39
  client starts on Claude accounts, every other client on ChatGPT accounts; --provider moves a client.
36
40
  Run gives one child a private credential that ends when the child exits; run claude uses Claude accounts
37
41
  unless --provider openai asks for ChatGPT models behind Claude Code.
42
+ confined run starts Claude Code's container on a credential of its own, accepted only on the proxy's confined
43
+ listener: a Unix socket whose directory the run mounts read-only at ${aglProxyConfinedMountPoint}, served the Claude pool's
44
+ three Claude Code endpoints alone. It adds the mount and Claude Code's settings (ANTHROPIC_UNIX_SOCKET, ANTHROPIC_BASE_URL,
45
+ ANTHROPIC_AUTH_TOKEN) right after docker run, prints the run's id on standard error, and ends the credential when the
46
+ container exits. The socket is the proxy user's (0600): run the container as that uid (--user), or as root under
47
+ rootless Docker. Root of a rootful daemon connects as well while it keeps CAP_DAC_OVERRIDE (--cap-drop ALL takes it).
48
+ confined end ends a run's credential at once, and its launcher stops the container; agl proxy status lists the runs.
38
49
  Repeat --account or --plan to restrict automatic routing. Only accounts the account service holds are eligible.
39
50
  ChatGPT pools: /openai/v1/responses, /anthropic/v1/messages (translated), and each prefix's /models.
40
51
  Claude pools: /anthropic/v1/messages, /anthropic/v1/messages/count_tokens and /anthropic/v1/models, sent on as Claude Code sent them.
@@ -44,14 +55,18 @@ Use x-agl-session-id for custom clients; native session headers and prompt_cache
44
55
  Switches use full history with a cold cache. Provider-only references require a full-history retry; opaque account state never crosses accounts.
45
56
  `;
46
57
 
47
- export type TInferenceCommand = 'serve' | 'run' | 'credential' | 'status' | 'service';
58
+ export type TInferenceCommand = 'serve' | 'run' | 'credential' | 'status' | 'service' | 'confined';
48
59
  export type TInferenceServiceAction = 'install' | 'enable' | 'start' | 'stop' | 'status';
60
+ export type TInferenceConfinedAction = 'run' | 'end';
49
61
 
50
62
  export interface IInferenceCliArguments {
51
63
  command: TInferenceCommand;
52
64
  harness?: TInferenceHarness;
53
65
  client?: string;
54
66
  serviceAction?: TInferenceServiceAction;
67
+ confinedAction?: TInferenceConfinedAction;
68
+ /** `confined end`: the run to end. */
69
+ confinedId?: string;
55
70
  port: number;
56
71
  policy: IInferenceRoutingPolicy;
57
72
  /** `--provider`, when given. */
@@ -72,6 +87,7 @@ const commandOptions: Record<TInferenceCommand, readonly string[]> = {
72
87
  credential: ['--provider', '--delivery', '--write', '--rotate'],
73
88
  status: ['--json'],
74
89
  service: ['--port'],
90
+ confined: [],
75
91
  };
76
92
  const flags = new Set(['--rotate', '--json']);
77
93
 
@@ -97,6 +113,21 @@ export const parseInferenceCliArguments = (argvArg: readonly string[]): IInferen
97
113
  const action = argv.shift();
98
114
  if (!['install', 'enable', 'start', 'stop', 'status'].includes(action ?? '')) throw new Error(inferenceCliHelp);
99
115
  result.serviceAction = action as TInferenceServiceAction;
116
+ } else if (command === 'confined') {
117
+ const action = argv.shift();
118
+ if (action === 'end') {
119
+ const id = argv.shift();
120
+ if (id === undefined || !aglProxyConfinedIdPattern.test(id) || argv.length) {
121
+ throw new Error('Name the confined run to end by its id: 12 hex characters, as `agl proxy status` lists it.');
122
+ }
123
+ result.confinedAction = 'end';
124
+ result.confinedId = id;
125
+ return result;
126
+ }
127
+ if (action !== 'run' || argv.shift() !== '--' || !argv.length) throw new Error(inferenceCliHelp);
128
+ result.confinedAction = 'run';
129
+ result.args = argv;
130
+ return result;
100
131
  }
101
132
  const accounts: string[] = [];
102
133
  const plans: string[] = [];
@@ -1,7 +1,9 @@
1
+ import * as path from 'node:path';
1
2
  import type { TInferenceModel } from './classes.inferencerouter.js';
2
3
  import type { TAglProxyLeaseCatalog } from './classes.proxycontrol.js';
3
- import { aglProxyProviderId, claudeProxyEnvironment, claudeRoutingVariables, codexProxyProvider, openCodeProxyProvider,
4
- proxyHarnessModels } from './functions.proxyharnessconfig.js';
4
+ import { aglProxyConfinedMountPoint } from './functions.proxycontract.js';
5
+ import { aglProxyProviderId, claudeProxyEnvironment, claudeProxyReservedVariables, claudeRoutingVariables, codexProxyProvider,
6
+ openCodeProxyProvider, proxyHarnessModels } from './functions.proxyharnessconfig.js';
5
7
 
6
8
  export type TInferenceHarness = 'codex' | 'claude' | 'opencode';
7
9
  export interface IInferenceLaunchOptions {
@@ -137,3 +139,48 @@ export const inferenceHarnessLaunch = (optionsArg: IInferenceLaunchOptions): { a
137
139
  if (args[0] === 'run') return { environment, args: ['run', ...selection, ...args.slice(1)] };
138
140
  return { environment, args: args[0] !== undefined && openCodeCommands.has(args[0]) ? args : [...selection, ...args] };
139
141
  };
142
+
143
+ /**
144
+ * A confined run's `docker run`: the given command, with the confined listener's directory mounted read-only at
145
+ * `aglProxyConfinedMountPoint` and Claude Code's settings for it added right after `run`, before the launcher's own
146
+ * options and image. Claude Code inside reaches the proxy through the socket alone (`ANTHROPIC_UNIX_SOCKET`, which
147
+ * Claude Code sends its API requests through as plain HTTP to `ANTHROPIC_BASE_URL`'s path), on the run's credential
148
+ * (`ANTHROPIC_AUTH_TOKEN`), reads the pool's models from the proxy and sends nothing beyond its model requests. The
149
+ * container gets each variable by name from the docker command's environment, so the credential never appears on a
150
+ * command line; that environment drops every variable that would give Claude Code another login or route, so `-e NAME`
151
+ * of one of them passes nothing. The mount is the socket's own directory, which holds nothing else and survives a proxy
152
+ * restart, unlike a mount of the socket file. Only `docker run` and `docker container run` are taken.
153
+ */
154
+ export const confinedDockerLaunch = (optionsArg: {
155
+ args: readonly string[];
156
+ /** The confined listener's socket on the host. */
157
+ socketPath: string;
158
+ /** The origin Claude Code names; see `IAglProxyConfinedGrant.baseUrl`. */
159
+ baseUrl: string;
160
+ token: string;
161
+ environment: NodeJS.ProcessEnv;
162
+ }): { command: string; args: string[]; environment: NodeJS.ProcessEnv } => {
163
+ const [command, ...args] = optionsArg.args;
164
+ const runAt = args[0] === 'run' ? 1 : args[0] === 'container' && args[1] === 'run' ? 2 : 0;
165
+ if (command === undefined || path.basename(command) !== 'docker' || runAt === 0) {
166
+ throw new Error('A confined run starts its container with `docker run` (or `docker container run`) and its arguments.');
167
+ }
168
+ const directory = path.dirname(optionsArg.socketPath);
169
+ // `--mount` separates its fields with commas and reads quotes; a directory naming either cannot be passed safely.
170
+ if (/[,"'\n]/u.test(directory)) throw new Error(`The confined listener's directory ${directory} cannot be mounted: it holds a comma, quote or line break.`);
171
+ const variables: Record<string, string> = {
172
+ ...claudeProxyEnvironment(optionsArg.baseUrl),
173
+ ANTHROPIC_UNIX_SOCKET: `${aglProxyConfinedMountPoint}/${path.basename(optionsArg.socketPath)}`,
174
+ ANTHROPIC_AUTH_TOKEN: optionsArg.token,
175
+ };
176
+ const environment = { ...optionsArg.environment };
177
+ for (const name of claudeProxyReservedVariables) delete environment[name];
178
+ return {
179
+ command,
180
+ args: [...args.slice(0, runAt),
181
+ '--mount', `type=bind,source=${directory},target=${aglProxyConfinedMountPoint},readonly`,
182
+ ...Object.keys(variables).flatMap((nameArg) => ['--env', nameArg]),
183
+ ...args.slice(runAt)],
184
+ environment: { ...environment, ...variables },
185
+ };
186
+ };
@@ -4,7 +4,7 @@ import type { TTicketOperation } from '../ts_interfaces/tickets.js';
4
4
  const workflow = ' Caller identity comes from AGL, never a supplied name. Unidentified callers may list/get tickets with empty mine; every mutation requires a verified caller. Workers create in backlog and may update/move only their assigned tickets within ready, in_progress, needs_input and review. Link a branch with a full sha before Review. Only the driver or owner assigns, lands or releases tickets.';
5
5
  export const ticketToolDefinitions = [
6
6
  { name: 'tickets_list', operation: 'list', description: 'Read the shared board across all projects. Filter by mine, project, status, label or text. mine uses your verified session. label also matches AutoLabels, which AGL computes and nobody sets: openQuestion marks a ticket with an unanswered question.' },
7
- { name: 'ticket_get', operation: 'get', description: 'Read a ticket by its AGL-<n> key, with markdown comments, source, links, its questions and their answers, and the complete activity log.' },
7
+ { name: 'ticket_get', operation: 'get', description: 'Read a ticket by its AGL-<n> key: source, links, all its questions and their answers, and the newest page of its history, oldest first: up to limit entries (default 50, at most 500), fewer when the entries and their comments would pass 256 KiB. activity is the page; comments are the markdown comments it names, and a commented entry names its comment by id. historyBefore is present while older history exists: pass it as before for the next older page. commentId instead reads the one comment an activity entry or fleet event names (without before or limit).' },
8
8
  { name: 'ticket_create', operation: 'create', description: 'Capture discussed work as a ticket. Include project, title, description and sourceText (the original words); optional attachments are named URLs. Worker-created tickets start in backlog for driver triage.' },
9
9
  { name: 'ticket_update', operation: 'update', description: 'Update the title, markdown description, priority, labels, project or parent of your assigned ticket. Supply expectedRevision from the ticket you read; stale edits return TICKET_REVISION_CONFLICT with currentRevision. Reload before editing again. Status is changed only with ticket_move; assignment is driver/owner-only.' },
10
10
  { name: 'ticket_move', operation: 'move', description: 'Atomically move/reorder your assigned ticket. Omit beforeTicketId to append. Returns every record whose column or order changed. Review requires a linked branch and sha. Use needs_input to report a blocker.' },
@@ -33,3 +33,15 @@ export const proxyCredentialDeliveries: readonly TProxyCredentialDelivery[] = ['
33
33
 
34
34
  export const aglProxyUnitName = 'agl-proxy.service';
35
35
  export const aglProxyDefaultPort = 4100;
36
+
37
+ /**
38
+ * Confined runs (`agl proxy confined run`): Claude Code in a container that reaches the proxy only through the confined
39
+ * listener, a Unix socket alone in its directory, which the run mounts read-only at `aglProxyConfinedMountPoint`. Their
40
+ * sessions are those of the client `aglProxyConfinedClient`, and Claude Code names `aglProxyConfinedOrigin` as its
41
+ * endpoint's origin, whose host the socket makes irrelevant.
42
+ */
43
+ export const aglProxyConfinedClient = 'confined';
44
+ export const aglProxyConfinedOrigin = 'http://localhost';
45
+ export const aglProxyConfinedMountPoint = '/run/agl-proxy';
46
+ /** A confined run's id: the first 12 hex characters of its credential's SHA-256 digest. */
47
+ export const aglProxyConfinedIdPattern = /^[a-f0-9]{12}$/;
@@ -0,0 +1,39 @@
1
+ import * as plugins from './plugins.js';
2
+
3
+ /**
4
+ * Claims the pathname of one of the proxy's Unix sockets: its directory is made private (0700, this user's, not a link),
5
+ * and a socket a killed proxy left there is removed, but only while it is an unchanged, owned socket nobody answers on.
6
+ * `nameArg` names the socket in the refusals; `busyArg` is the refusal for a socket another process answers on.
7
+ */
8
+ export const claimProxySocketPath = async (socketPathArg: string, nameArg: string, busyArg: string): Promise<void> => {
9
+ const directory = plugins.path.dirname(socketPathArg);
10
+ await plugins.fs.promises.mkdir(directory, { recursive: true, mode: 0o700 });
11
+ const stat = await plugins.fs.promises.lstat(directory);
12
+ if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== process.getuid?.()) {
13
+ throw new Error(`The ${nameArg} directory is not private.`);
14
+ }
15
+ await plugins.fs.promises.chmod(directory, 0o700);
16
+ let before: plugins.fs.Stats;
17
+ try { before = await plugins.fs.promises.lstat(socketPathArg); }
18
+ catch (errorArg) {
19
+ if ((errorArg as NodeJS.ErrnoException).code === 'ENOENT') return;
20
+ throw errorArg;
21
+ }
22
+ if (!before.isSocket() || before.uid !== process.getuid?.()) throw new Error(`The ${nameArg} path is unsafe.`);
23
+ const refused = await new Promise<boolean>((resolve, reject) => {
24
+ const probe = plugins.net.createConnection(socketPathArg);
25
+ probe.setTimeout(1_000, () => { probe.destroy(); reject(new Error(`The ${nameArg} probe timed out.`)); });
26
+ probe.once('connect', () => { probe.destroy(); resolve(false); });
27
+ probe.once('error', (errorArg) => {
28
+ probe.destroy();
29
+ if ((errorArg as NodeJS.ErrnoException).code === 'ECONNREFUSED') resolve(true);
30
+ else reject(errorArg);
31
+ });
32
+ });
33
+ if (!refused) throw new Error(busyArg);
34
+ const after = await plugins.fs.promises.lstat(socketPathArg);
35
+ if (!after.isSocket() || after.dev !== before.dev || after.ino !== before.ino) {
36
+ throw new Error(`The ${nameArg} path changed during startup.`);
37
+ }
38
+ await plugins.fs.promises.unlink(socketPathArg);
39
+ };
@@ -8,7 +8,10 @@ import type { ITicketChanges, ITicketPerson, TTicketOperation, TTicketRequest, T
8
8
  export const ticketCliHelp = `Usage: agl ticket <list|get|create|update|move|comment|link|assign|ask|answer> [AGL-<n>] [options]
9
9
  list [--mine] [--project ID] [--status COLUMN] [--label LABEL] [--text TEXT]
10
10
  --label also matches AutoLabels, which AGL computes: openQuestion (an unanswered question)
11
- get AGL-<n>
11
+ get AGL-<n> [--limit N] [--before HISTORY-ID] [--comment COMMENT-ID]
12
+ The ticket, its questions and the newest N history entries (default 50, at most 500, within 256 KiB);
13
+ historyBefore names the next older page: pass it as --before.
14
+ --comment reads the one comment an activity entry names instead (not with --limit or --before)
12
15
  create --project ID --title TEXT [--description MARKDOWN] [--kind epic|task|subtask]
13
16
  [--parent AGL-<n>] [--priority none|low|normal|high|urgent] [--labels a,b]
14
17
  [--source TEXT] [--attachments JSON] [--column COLUMN]
@@ -38,7 +41,7 @@ export interface ITicketCliArguments {
38
41
  }
39
42
  const common = ['port', 'json'];
40
43
  const actionOptions: Record<string, string[]> = {
41
- list: ['mine', 'project', 'status', 'label', 'text'], get: [],
44
+ list: ['mine', 'project', 'status', 'label', 'text'], get: ['limit', 'before', 'comment'],
42
45
  create: ['project', 'title', 'description', 'kind', 'parent', 'priority', 'labels', 'source', 'attachments', 'column'],
43
46
  update: ['expected-revision', 'changes', 'title', 'description', 'priority', 'project', 'labels', 'parent', 'clear-parent'],
44
47
  move: ['column', 'before'], comment: ['body'], link: ['repo', 'branch', 'sha', 'package', 'version'],
@@ -78,7 +81,8 @@ export const parseTicketCliArguments = (argumentsArg: string[]): ITicketCliArgum
78
81
  const operation = action === 'assign' ? 'update' : action as TTicketOperation;
79
82
  if (action === 'list') request = { ...(options.has('mine') ? { mine: true } : {}), ...optional('project'),
80
83
  ...optional('status'), ...optional('label'), ...optional('text') };
81
- else if (action === 'get') request = { ticketId };
84
+ else if (action === 'get') request = { ticketId, ...optional('before'), ...optional('comment', 'commentId'),
85
+ ...(options.has('limit') ? { limit: Number(required('limit')) } : {}) };
82
86
  else if (action === 'create') request = { project: required('project'), title: required('title'), ...optional('description'),
83
87
  ...optional('kind'), ...optional('parent', 'parentId'), ...optional('priority'), ...labels(), ...optional('source', 'sourceText'),
84
88
  ...optional('column', 'columnId'), ...(options.has('attachments') ? { attachments: JSON.parse(required('attachments')) } : {}) };
@@ -1,5 +1,5 @@
1
1
  import * as plugins from './plugins.js';
2
- import { ticketAutoLabels, ticketColumns, type TTicketOperation, type TTicketRequest } from '../ts_interfaces/tickets.js';
2
+ import { ticketAutoLabels, ticketColumns, ticketHistoryPage, type TTicketOperation, type TTicketRequest } from '../ts_interfaces/tickets.js';
3
3
 
4
4
  const text = (maximumArg: number) => plugins.z.string().max(maximumArg);
5
5
  const identifier = text(1024).min(1).refine((valueArg) => !/[\x00-\x1f\x7f]/u.test(valueArg));
@@ -38,7 +38,11 @@ const changes = plugins.z.object({
38
38
  export const ticketInputSchemas = {
39
39
  list: filters, board: filters,
40
40
  search: plugins.z.object({ query: text(1024), label: text(100).min(1).optional() }).strict(),
41
- get: plugins.z.object({ ticketId }).strict(), comments: plugins.z.object({ ticketId }).strict(),
41
+ get: plugins.z.object({ ticketId, before: identifier.optional(),
42
+ limit: plugins.z.number().int().min(1).max(ticketHistoryPage.maximum).optional(), commentId: identifier.optional() }).strict()
43
+ .refine((valueArg) => valueArg.commentId === undefined || (valueArg.before === undefined && valueArg.limit === undefined),
44
+ 'commentId reads one comment and excludes before and limit.'),
45
+ comments: plugins.z.object({ ticketId }).strict(),
42
46
  create: plugins.z.object({ project: identifier, kind: kind.optional(), title: text(200).trim().min(1),
43
47
  description: text(64 * 1024).optional(), columnId: plugins.z.enum(ticketColumns).optional(), parentId: ticketId.optional(),
44
48
  priority: priority.optional(), assignee: person.optional(), labels: labels.optional(), sourceText: text(64 * 1024).optional(),
@@ -117,7 +121,7 @@ export const assertTicketResponse = (methodArg: string, valueArg: unknown): void
117
121
  id: plugins.z.enum(ticketColumns), title: plugins.z.string(), done: plugins.z.boolean().optional() }).strict()), tickets: plugins.z.array(ticket),
118
122
  viewer: person }).strict(),
119
123
  get: plugins.z.object({ ticket, comments: plugins.z.array(ticketCommentSchema), activity: plugins.z.array(ticketActivitySchema),
120
- questions: plugins.z.array(ticketQuestionSchema) }).strict(),
124
+ questions: plugins.z.array(ticketQuestionSchema), historyBefore: identifier.optional() }).strict(),
121
125
  comments: plugins.z.object({ comments: plugins.z.array(ticketCommentSchema) }).strict(),
122
126
  create: plugins.z.object({ ticket }).strict(), update: plugins.z.object({ ticket }).strict(),
123
127
  link: plugins.z.object({ ticket }).strict(), move: plugins.z.object({ tickets: plugins.z.array(ticket) }).strict(),
@@ -142,21 +142,44 @@ const processOwnsLoopbackConnection = async (connectionArg: IWorkerCallerConnect
142
142
  return matches.length === 1 && await processOwnsInode(pidArg, matches[0][9]);
143
143
  };
144
144
 
145
- export const verifyWorkerBridge = async (
145
+ /**
146
+ * The bridge process `bridgePidArg`, proved to hold the caller's end of the direct loopback connection and to run the
147
+ * approved command, whatever its parent. The command is the one recorded when the launch was prepared: an upgrade
148
+ * installs AGL under another path, and a command whose files are gone is refused as such.
149
+ */
150
+ export const readWorkerBridge = async (
146
151
  connectionArg: IWorkerCallerConnection | undefined,
147
152
  bridgePidArg: number,
148
153
  commandArg: IWorkerBridgeCommand,
149
- parentArg: IWorkerProcessSnapshot,
150
154
  ): Promise<IWorkerProcessSnapshot> => {
155
+ for (const file of [commandArg.executable, commandArg.entry]) {
156
+ let resolved: string;
157
+ try { resolved = await plugins.fs.promises.realpath(file); } catch (errorArg) {
158
+ if (isWorkerCallerProofRefusal(errorArg)) throw new WorkerCallerProofError('The approved bridge command no longer exists.');
159
+ throw errorArg;
160
+ }
161
+ if (resolved !== file) throw new WorkerCallerProofError('The approved bridge command no longer exists.');
162
+ }
151
163
  if (!await processOwnsLoopbackConnection(connectionArg, bridgePidArg)) {
152
164
  throw new WorkerCallerProofError('Worker transport ownership could not be proved.');
153
165
  }
154
166
  const bridge = await readWorkerProcessSnapshot(bridgePidArg);
155
167
  const entry = await plugins.fs.promises.realpath(bridge.arguments[1] ?? '');
156
- if (bridge.parentPid !== parentArg.pid || bridge.executable !== commandArg.executable
157
- || entry !== commandArg.entry || !plugins.util.isDeepStrictEqual(bridge.arguments.slice(2), commandArg.arguments)) {
168
+ if (bridge.executable !== commandArg.executable || entry !== commandArg.entry
169
+ || !plugins.util.isDeepStrictEqual(bridge.arguments.slice(2), commandArg.arguments)) {
158
170
  throw new WorkerCallerProofError('Worker bridge does not match the approved launch.');
159
171
  }
172
+ return bridge;
173
+ };
174
+
175
+ export const verifyWorkerBridge = async (
176
+ connectionArg: IWorkerCallerConnection | undefined,
177
+ bridgePidArg: number,
178
+ commandArg: IWorkerBridgeCommand,
179
+ parentArg: IWorkerProcessSnapshot,
180
+ ): Promise<IWorkerProcessSnapshot> => {
181
+ const bridge = await readWorkerBridge(connectionArg, bridgePidArg, commandArg);
182
+ if (bridge.parentPid !== parentArg.pid) throw new WorkerCallerProofError('Worker bridge does not match the approved launch.');
160
183
  if (!workerSnapshotsEqual(parentArg, await readWorkerProcessSnapshot(parentArg.pid))
161
184
  || !workerSnapshotsEqual(bridge, await readWorkerProcessSnapshot(bridge.pid))) {
162
185
  throw new WorkerCallerProofError('Worker transport or process incarnation could not be proved.');
@@ -23,6 +23,7 @@ import {
23
23
  type TFlexCreateSessionOptions = plugins.flexharness.IFlexCreateSessionOptions;
24
24
  type TFlexMessage = plugins.flexharness.IFlexMessage;
25
25
  type TFlexMessagePage = plugins.flexharness.IFlexMessagePage;
26
+ type TFlexMessageMetadataPage = plugins.flexharness.IFlexMessageMetadataPage;
26
27
  type TFlexPermissionRequest = plugins.flexharness.IFlexPermissionRequest;
27
28
  export type TFlexPromptQueueEntry = plugins.flexharness.IFlexPromptQueueEntry;
28
29
  type TFlexPromptOptions = plugins.flexharness.IFlexPromptOptions;
@@ -67,7 +68,7 @@ export type TFlexTurnReversionCaptureInspectionV2 =
67
68
  export type TFlexTurnReversionApplyInspection =
68
69
  plugins.flexharness.IFlexTurnReversionApplyInspection;
69
70
 
70
- export const flexIpcProtocolVersion = 38 as const;
71
+ export const flexIpcProtocolVersion = 39 as const;
71
72
  /**
72
73
  * The installation's own Flex runs, which belong to no project: the configuration projection's adapting run, and
73
74
  * Session Intelligence over a session outside every project.
@@ -307,6 +308,8 @@ export interface IFlexDriverRequest {
307
308
  export interface IFlexDriverResolution {
308
309
  instructions: string;
309
310
  notes: string;
311
+ /** The revision of the session scratchpad that holds the notes; names them as the driver's pinned context. */
312
+ notesRevision: number;
310
313
  }
311
314
  export const flexDriverToolNamePattern = /^[a-z][a-z0-9_]{0,63}$/u;
312
315
  export const flexDriverToolInputMaxBytes = 2 * 1024 * 1024;
@@ -363,7 +366,7 @@ export interface IFlexHostRequestMap {
363
366
  request: IFlexTurnReversionReleaseRequest;
364
367
  response: { released: true };
365
368
  };
366
- /** The controller-owned driver session's instructions and notes; null for every other session. */
369
+ /** The controller-owned driver session's instructions and notes with their revision; null for every other session. */
367
370
  'driver.resolve': {
368
371
  request: IFlexDriverRequest;
369
372
  response: { driver: IFlexDriverResolution | null };
@@ -836,6 +839,14 @@ export interface IFlexRequestMap {
836
839
  request: { scopeId: string; sessionId: string; limit?: number; before?: string; includeUndone?: boolean };
837
840
  response: TFlexMessagePage;
838
841
  };
842
+ /**
843
+ * The same pages with each message's identity and state only (no parts, model, usage or error):
844
+ * a whole transcript is scanned without reading its text or tool output.
845
+ */
846
+ 'message.metadata.page': {
847
+ request: { scopeId: string; sessionId: string; limit?: number; before?: string; includeUndone?: boolean };
848
+ response: TFlexMessageMetadataPage;
849
+ };
839
850
  'message.get': {
840
851
  request: { scopeId: string; sessionId: string; messageId: string; includeUndone?: boolean };
841
852
  response: TFlexMessage;
@@ -1358,6 +1369,7 @@ export const readOnlyFlexRequestMethods: ReadonlySet<TFlexRequestMethod> = new S
1358
1369
  'session.reversion.info',
1359
1370
  'slash.list',
1360
1371
  'message.page',
1372
+ 'message.metadata.page',
1361
1373
  'message.get',
1362
1374
  'prompt.get',
1363
1375
  'prompt.list',
@@ -1387,6 +1399,7 @@ const requestMethods: ReadonlySet<string> = new Set<TFlexRequestMethod>([
1387
1399
  'slash.list',
1388
1400
  'slash.execute',
1389
1401
  'message.page',
1402
+ 'message.metadata.page',
1390
1403
  'message.get',
1391
1404
  'prompt.start',
1392
1405
  'prompt.continue',
@@ -1919,28 +1932,22 @@ const isFlexModelLimit = (valueArg: unknown): valueArg is TFlexModelLimit => (
1919
1932
  isFlexPlainObject(valueArg) && plugins.flexModels.isModelLimitInfo(valueArg)
1920
1933
  );
1921
1934
 
1922
- export const isFlexPublicMessage = (valueArg: unknown): valueArg is TFlexMessage =>
1923
- isFlexPlainObject(valueArg)
1924
- && hasFlexExactKeys(
1925
- valueArg,
1926
- ['messageId', 'sessionId', 'runId', 'role', 'status', 'createdAt', 'parts'],
1927
- ['completedAt', 'model', 'usage', 'error', 'limit', 'rejectedPermissionId', 'steerId', 'cacheWarm', 'promptId', 'keptInContext', 'undone'],
1928
- )
1929
- && isIdentifier(valueArg.messageId)
1935
+ const flexMessageIdentityKeys = ['messageId', 'sessionId', 'runId', 'role', 'status', 'createdAt'] as const;
1936
+ const flexMessageOptionalIdentityKeys = ['completedAt', 'steerId', 'promptId', 'keptInContext', 'undone', 'cacheWarm'] as const;
1937
+ const flexMessageKeys = [...flexMessageIdentityKeys, 'parts'] as const;
1938
+ const flexMessageOptionalKeys = [
1939
+ ...flexMessageOptionalIdentityKeys, 'model', 'usage', 'error', 'limit', 'rejectedPermissionId',
1940
+ ] as const;
1941
+
1942
+ /** The identity and state a transcript message and its metadata share (`TFlexMessageMetadata`). */
1943
+ const hasFlexMessageIdentity = (valueArg: Record<string, unknown>): boolean =>
1944
+ isIdentifier(valueArg.messageId)
1930
1945
  && isIdentifier(valueArg.sessionId)
1931
1946
  && isIdentifier(valueArg.runId)
1932
1947
  && ['user', 'assistant'].includes(String(valueArg.role))
1933
1948
  && ['streaming', 'completed', 'failed', 'cancelled'].includes(String(valueArg.status))
1934
1949
  && isIsoDate(valueArg.createdAt)
1935
1950
  && (valueArg.completedAt === undefined || isIsoDate(valueArg.completedAt))
1936
- && Array.isArray(valueArg.parts)
1937
- && valueArg.parts.length <= 2000
1938
- && valueArg.parts.every((partArg) => isMessagePart(partArg))
1939
- && (valueArg.model === undefined || isModelIdentity(valueArg.model))
1940
- && (valueArg.usage === undefined || isUsage(valueArg.usage))
1941
- && (valueArg.error === undefined || isBoundedString(valueArg.error, 2048))
1942
- && (valueArg.limit === undefined || isFlexModelLimit(valueArg.limit))
1943
- && (valueArg.rejectedPermissionId === undefined || isIdentifier(valueArg.rejectedPermissionId))
1944
1951
  && (valueArg.steerId === undefined || (valueArg.role === 'user' && isIdentifier(valueArg.steerId)))
1945
1952
  && (valueArg.promptId === undefined || (valueArg.role === 'user' && isBoundedString(valueArg.promptId, 512)))
1946
1953
  && !(valueArg.promptId !== undefined && valueArg.steerId !== undefined)
@@ -1949,6 +1956,25 @@ export const isFlexPublicMessage = (valueArg: unknown): valueArg is TFlexMessage
1949
1956
  && (valueArg.undone === undefined || valueArg.undone === true)
1950
1957
  && (valueArg.cacheWarm === undefined || valueArg.cacheWarm === true);
1951
1958
 
1959
+ export const isFlexPublicMessage = (valueArg: unknown): valueArg is TFlexMessage =>
1960
+ isFlexPlainObject(valueArg)
1961
+ && hasFlexExactKeys(valueArg, flexMessageKeys, flexMessageOptionalKeys)
1962
+ && hasFlexMessageIdentity(valueArg)
1963
+ && Array.isArray(valueArg.parts)
1964
+ && valueArg.parts.length <= 2000
1965
+ && valueArg.parts.every((partArg) => isMessagePart(partArg))
1966
+ && (valueArg.model === undefined || isModelIdentity(valueArg.model))
1967
+ && (valueArg.usage === undefined || isUsage(valueArg.usage))
1968
+ && (valueArg.error === undefined || isBoundedString(valueArg.error, 2048))
1969
+ && (valueArg.limit === undefined || isFlexModelLimit(valueArg.limit))
1970
+ && (valueArg.rejectedPermissionId === undefined || isIdentifier(valueArg.rejectedPermissionId));
1971
+
1972
+ /** A transcript message's identity and state as `message.metadata.page` carries it: no content. */
1973
+ export const isFlexMessageMetadata = (valueArg: unknown): valueArg is plugins.flexharness.TFlexMessageMetadata =>
1974
+ isFlexPlainObject(valueArg)
1975
+ && hasFlexExactKeys(valueArg, flexMessageIdentityKeys, flexMessageOptionalIdentityKeys)
1976
+ && hasFlexMessageIdentity(valueArg);
1977
+
1952
1978
  export const isFlexPublicSession = (valueArg: unknown): valueArg is TFlexSession => {
1953
1979
  if (
1954
1980
  !isFlexPlainObject(valueArg)
@@ -2573,6 +2599,7 @@ export const isFlexRequestPayload = <TMethod extends TFlexRequestMethod>(
2573
2599
  && isScopeAndSession(valueArg)
2574
2600
  && isUpdateSessionOptions(valueArg.options);
2575
2601
  case 'message.page':
2602
+ case 'message.metadata.page':
2576
2603
  return hasFlexExactKeys(valueArg, ['scopeId', 'sessionId'], ['limit', 'before', 'includeUndone'])
2577
2604
  && isScopeAndSession(valueArg)
2578
2605
  && (valueArg.limit === undefined
@@ -3118,10 +3145,11 @@ export const isFlexHostResponseResult = <TMethod extends TFlexHostRequestMethod>
3118
3145
  if (!hasFlexExactKeys(valueArg, ['driver'])) return false;
3119
3146
  const driver = valueArg.driver;
3120
3147
  return driver === null || (isFlexPlainObject(driver)
3121
- && hasFlexExactKeys(driver, ['instructions', 'notes'])
3148
+ && hasFlexExactKeys(driver, ['instructions', 'notes', 'notesRevision'])
3122
3149
  && isBoundedString(driver.instructions, 64 * 1024)
3123
3150
  && isBoundedString(driver.notes, 128 * 1024, true)
3124
- && driver.notes.length <= 32_768);
3151
+ && driver.notes.length <= 32_768
3152
+ && isNonNegativeInteger(driver.notesRevision));
3125
3153
  }
3126
3154
  if (methodArg === 'driver.tool') {
3127
3155
  if (Object.hasOwn(valueArg, 'error')) {
@@ -3441,11 +3469,12 @@ export const isFlexResponseResult = <TMethod extends TFlexRequestMethod>(
3441
3469
  case 'slash.execute':
3442
3470
  return isSlashExecutionResult(valueArg);
3443
3471
  case 'message.page':
3472
+ case 'message.metadata.page':
3444
3473
  return isFlexPlainObject(valueArg)
3445
3474
  && hasFlexExactKeys(valueArg, ['messages', 'startIndex'], ['nextCursor'])
3446
3475
  && Array.isArray(valueArg.messages) && valueArg.messages.length <= 50
3447
3476
  && Number.isSafeInteger(valueArg.startIndex) && (valueArg.startIndex as number) >= 0
3448
- && valueArg.messages.every(isFlexPublicMessage)
3477
+ && valueArg.messages.every(methodArg === 'message.page' ? isFlexPublicMessage : isFlexMessageMetadata)
3449
3478
  && (valueArg.nextCursor === undefined || isBoundedString(valueArg.nextCursor, 4096));
3450
3479
  case 'message.get':
3451
3480
  return isFlexPublicMessage(valueArg);
@@ -228,6 +228,12 @@ export interface IFleetEvent {
228
228
  /** The native session (`harness:nativeId`) a ticket notice addressed. */
229
229
  sessionId?: string;
230
230
  reportId?: string;
231
+ /**
232
+ * The comment on `ticketKey` that holds this event's whole text, by the id the ticket board returned: a worker's report
233
+ * the fleet commented (`worker.report.mid_turn`, `worker.turn.ended`), the board pump's stall notice, a commented
234
+ * `ticket.changed`. Read it with `ticket_get` and `commentId`. Absent when no comment holds the text.
235
+ */
236
+ commentId?: string;
231
237
  turnId?: string;
232
238
  status?: string;
233
239
  text?: string;
@@ -5,7 +5,7 @@ import type {
5
5
  TControllerBrowserPaintAvailability,
6
6
  TControllerBrowserPaintTermination,
7
7
  } from './browserpaint.js';
8
- export const controllerProtocolVersion = 45 as const;
8
+ export const controllerProtocolVersion = 46 as const;
9
9
  export const controllerUpgradeManagementVersion = 2 as const;
10
10
  export const controllerPackageName = 'agl' as const;
11
11
  /** The systemd user unit `agl service install` writes; a controller it runs reports it in its status. */