toolroll 0.9.57 → 0.9.59

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 (73) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/action-ledger.js +10 -5
  3. package/dist/actor.d.ts +0 -10
  4. package/dist/actor.js +1 -18
  5. package/dist/api-tokens.d.ts +33 -2
  6. package/dist/api-tokens.js +39 -11
  7. package/dist/assignment-delivery.js +1 -1
  8. package/dist/assignment-status.js +1 -1
  9. package/dist/assignment.js +3 -2
  10. package/dist/browser/workspace.js +1 -1
  11. package/dist/builder.js +4 -2
  12. package/dist/chat-channel.d.ts +1 -0
  13. package/dist/chat-channel.js +3 -0
  14. package/dist/chat-delivery.js +2 -0
  15. package/dist/cli-http.d.ts +7 -0
  16. package/dist/cli-http.js +30 -4
  17. package/dist/cli.js +2 -1
  18. package/dist/contract-changes.d.ts +52 -0
  19. package/dist/contract-changes.js +61 -0
  20. package/dist/coordinator.d.ts +2 -1
  21. package/dist/coordinator.js +22 -31
  22. package/dist/decision.d.ts +4 -0
  23. package/dist/decision.js +8 -0
  24. package/dist/deploy-lock.d.ts +8 -0
  25. package/dist/deploy-lock.js +45 -0
  26. package/dist/desktop-update.d.ts +1 -1
  27. package/dist/dispatch.js +1 -1
  28. package/dist/export.js +2 -2
  29. package/dist/lead-cli.js +3 -0
  30. package/dist/lead-doors.js +3 -0
  31. package/dist/lead-tools.js +2 -1
  32. package/dist/mcp-http.js +5 -5
  33. package/dist/needs-you.js +3 -1
  34. package/dist/operate.js +23 -11
  35. package/dist/password-bearer.d.ts +27 -0
  36. package/dist/password-bearer.js +59 -0
  37. package/dist/planner-source.d.ts +23 -53
  38. package/dist/planner-source.js +43 -68
  39. package/dist/release-gate.d.ts +2 -3
  40. package/dist/release-gate.js +44 -16
  41. package/dist/remote-command.js +0 -2
  42. package/dist/remote-exec.d.ts +19 -0
  43. package/dist/remote-exec.js +9 -8
  44. package/dist/request-budget.d.ts +1 -1
  45. package/dist/scope.js +16 -1
  46. package/dist/serve.js +15 -0
  47. package/dist/server/edge-refusal.d.ts +2 -0
  48. package/dist/server/edge-refusal.js +8 -6
  49. package/dist/server/guards.js +5 -4
  50. package/dist/server/http.d.ts +2 -0
  51. package/dist/server/remote.js +6 -13
  52. package/dist/server/render-chat.js +10 -0
  53. package/dist/server/render-tasks.js +19 -9
  54. package/dist/server/route-table.js +0 -3
  55. package/dist/server/tasks.js +3 -1
  56. package/dist/session-cli.d.ts +38 -9
  57. package/dist/session-cli.js +141 -154
  58. package/dist/session-contract.js +2 -2
  59. package/dist/session-remote.d.ts +19 -0
  60. package/dist/session-remote.js +86 -0
  61. package/dist/store.d.ts +72 -12
  62. package/dist/store.js +367 -105
  63. package/dist/task-status.js +3 -1
  64. package/dist/team-cli.d.ts +3 -0
  65. package/dist/team-cli.js +23 -3
  66. package/dist/telegram-lead.js +6 -0
  67. package/dist/telegram.js +9 -7
  68. package/dist/toolroll-update.js +10 -1
  69. package/package.json +1 -1
  70. package/dist/session-http.d.ts +0 -24
  71. package/dist/session-http.js +0 -170
  72. package/dist/session-server.d.ts +0 -17
  73. package/dist/session-server.js +0 -39
@@ -1,19 +1,100 @@
1
1
  import { open } from 'node:fs/promises';
2
2
  import { envelopeJson } from './envelope.js';
3
- import { SESSION_DESCRIPTORS, SESSION_PROMPT_BYTES, SESSION_RESPONSE_BYTES, isSessionResponse, sessionCapabilities, sessionDescriptor, validateSessionRequest, } from './session-contract.js';
4
- const commonFlags = { url: 'value', as: 'value', 'token-env': 'value', 'token-file': 'value', json: 'flag', help: 'flag' };
3
+ import { SESSION_DESCRIPTORS, SESSION_PROMPT_BYTES, isSessionResponse, sessionCapabilities, sessionDescriptor, validateSessionRequest, } from './session-contract.js';
4
+ import { centralProfile, UsageError } from './team-cli.js';
5
+ import { postCli } from './remote-exec.js';
6
+ import { TOKEN_REPLACEMENT } from './password-bearer.js';
7
+ /** The files key the prompt travels under: the server reads no paths, only what the request carries. */
8
+ export const SESSION_PROMPT_FILE = 'prompt';
5
9
  export const SESSION_CLI_ACTIONS = ['capabilities', ...SESSION_DESCRIPTORS.map(one => one.operation)];
6
- export function sessionCliFlags(spec) {
7
- return { ...commonFlags, ...Object.fromEntries(Object.keys(spec.flags).map(name => [name, 'value'])), ...(spec.operation === 'start' || spec.operation === 'send' ? { file: 'value', stdin: 'flag' } : {}) };
10
+ /** Before D7 the session client signed in with an operator password on every request. */
11
+ const RETIRED_FLAGS = new Set(['url', 'as', 'token-env', 'token-file']);
12
+ export const SESSION_TOKEN_REQUIRED = `Session commands use your saved API token. ${TOKEN_REPLACEMENT}, then save it with \`toolroll connect <origin> --as <account> --token-stdin\`.`;
13
+ /** The flags an operation takes: on this computer (where the prompt may come from a file or stdin, and a saved
14
+ * profile is chosen), or on the server (where the prompt is only ever the request's own file). */
15
+ export function sessionCliFlags(spec, side = 'client') {
16
+ const prompt = spec.operation === 'start' || spec.operation === 'send';
17
+ return { json: 'flag', help: 'flag', ...(side === 'client' ? { profile: 'value' } : {}),
18
+ ...Object.fromEntries(Object.keys(spec.flags).map(name => [name, 'value'])),
19
+ ...(prompt ? { file: 'value', ...(side === 'client' ? { stdin: 'flag' } : {}) } : {}) };
8
20
  }
9
21
  function help(spec) {
10
22
  if (!spec)
11
- return `toolroll session <operation>\n\n${SESSION_DESCRIPTORS.map(one => ` ${one.operation.padEnd(12)} ${one.synopsis}`).join('\n')}\n capabilities Show this client's schemas and authority requirements\n\nUse session <operation> --help for required flags. Session controls require operator credentials. Chat is the separate coordinator conversation.`;
23
+ return `toolroll session <operation>\n\n${SESSION_DESCRIPTORS.map(one => ` ${one.operation.padEnd(12)} ${one.synopsis}`).join('\n')}\n capabilities Show this client's schemas and authority requirements\n\nUse session <operation> --help for required flags. Sessions run on the server you connected to with your API token (toolroll connect); they need instance operator access.`;
12
24
  const fields = spec.inputSchema.required.filter(one => !['version', 'sessionId', 'prompt'].includes(one));
13
25
  const required = fields.map(field => Object.entries(spec.flags).find(([, value]) => value.field === field)?.[0]).filter(Boolean);
14
- return `toolroll session ${spec.operation}${'sessionId' in spec.inputSchema.properties ? ' <session-id>' : ''}\n${spec.synopsis}.\n\nRequired: --url <service-origin> --as <operator>\n --token-env <variable-name> or --token-file <path>\n${required.map(name => ` --${name} <value>`).join('\n')}${'prompt' in spec.inputSchema.properties ? '\n --file <path> or --stdin (complete prompt, up to 64 KB)' : ''}\n${'expectedRevision' in spec.inputSchema.properties ? '\nUse the revision, nativeThreadId and turnId from session show. Use none for a null thread or turn.\n' : ''}\nOptions: ${Object.keys(sessionCliFlags(spec)).map(name => `--${name}`).join(' ')}\nCredentials are sent only to the named HTTPS service or loopback HTTP. Redirects and automatic mutation retries are disabled.`;
26
+ return `toolroll session ${spec.operation}${'sessionId' in spec.inputSchema.properties ? ' <session-id>' : ''}\n${spec.synopsis}.\n\n${required.length > 0 ? `Required:\n${required.map(name => ` --${name} <value>`).join('\n')}\n` : ''}${'prompt' in spec.inputSchema.properties ? ' --file <path> or --stdin (complete prompt, up to 64 KB)\n' : ''}${'expectedRevision' in spec.inputSchema.properties ? '\nUse the revision, nativeThreadId and turnId from session show. Use none for a null thread or turn.\n' : ''}\nOptions: ${Object.keys(sessionCliFlags(spec)).map(name => `--${name}`).join(' ')}\nRuns on the saved connection (or --profile <name>) over /api/cli with your API token. Mutations are never retried automatically.`;
15
27
  }
16
- class UsageError extends Error {
28
+ /** One parser for both sides of /api/cli: `<operation> [<session-id>] --flag value …`. */
29
+ export function parseSessionArguments(argv, side) {
30
+ const operation = argv[0] ?? '';
31
+ const spec = sessionDescriptor(operation);
32
+ if (!spec)
33
+ return { problem: `Unknown session operation. Use: ${SESSION_CLI_ACTIONS.join(', ')}.` };
34
+ const allowed = sessionCliFlags(spec, side);
35
+ const flags = {};
36
+ const positionals = [];
37
+ for (let index = 1; index < argv.length; index++) {
38
+ const argument = argv[index];
39
+ if (!argument.startsWith('-')) {
40
+ positionals.push(argument);
41
+ continue;
42
+ }
43
+ const name = argument === '-h' ? 'help' : argument.startsWith('--') ? argument.slice(2) : '';
44
+ if (side === 'client' && RETIRED_FLAGS.has(name))
45
+ return { problem: SESSION_TOKEN_REQUIRED };
46
+ if (!Object.hasOwn(allowed, name))
47
+ return { problem: `Unknown option ${argument} for session ${operation}.` };
48
+ if (Object.hasOwn(flags, name))
49
+ return { problem: `Option --${name} was repeated.` };
50
+ if (allowed[name] === 'flag')
51
+ flags[name] = true;
52
+ else {
53
+ const value = argv[++index];
54
+ if (!value || value.startsWith('-'))
55
+ return { problem: `--${name} requires a value.` };
56
+ flags[name] = value;
57
+ }
58
+ }
59
+ return { spec, flags, positionals };
60
+ }
61
+ /** The validated request the parsed arguments and prompt spell. */
62
+ export function sessionRequestOf({ spec, flags, positionals }, prompt) {
63
+ const request = { version: 1 };
64
+ if ('sessionId' in spec.inputSchema.properties) {
65
+ if (positionals.length !== 1)
66
+ return { ok: false, message: 'Name exactly one session ID.' };
67
+ request['sessionId'] = positionals[0];
68
+ }
69
+ else if (positionals.length)
70
+ return { ok: false, message: 'This operation takes no positional arguments.' };
71
+ for (const [name, field] of Object.entries(spec.flags)) {
72
+ const raw = flags[name];
73
+ if (typeof raw !== 'string')
74
+ continue;
75
+ if (field.kind === 'integer' && !/^\d+$/.test(raw))
76
+ return { ok: false, message: `--${name} must be a whole number.` };
77
+ request[field.field] = field.kind === 'integer' ? Number(raw) : field.kind === 'nullable-id' && raw === 'none' ? null : raw;
78
+ }
79
+ if (prompt !== undefined)
80
+ request['prompt'] = prompt;
81
+ return validateSessionRequest(spec.operation, request);
82
+ }
83
+ /** The exact argv the server runs for a validated request: always --json, the prompt as the request's one file. */
84
+ export function sessionArgv(spec, request) {
85
+ const fields = request;
86
+ const argv = ['session', spec.operation];
87
+ if (typeof fields['sessionId'] === 'string')
88
+ argv.push(fields['sessionId']);
89
+ for (const [name, field] of Object.entries(spec.flags)) {
90
+ const value = fields[field.field];
91
+ if (value !== undefined)
92
+ argv.push(`--${name}`, value === null ? 'none' : String(value));
93
+ }
94
+ if (typeof fields['prompt'] === 'string')
95
+ argv.push('--file', SESSION_PROMPT_FILE);
96
+ argv.push('--json');
97
+ return argv;
17
98
  }
18
99
  async function readBoundedFile(path, limit, label) {
19
100
  let file;
@@ -60,90 +141,6 @@ async function readStdin() {
60
141
  throw new UsageError('The prompt must be UTF-8 text.');
61
142
  }
62
143
  }
63
- /** Credentials are never sent to a URL with embedded authority, an API path,
64
- * query, fragment or an insecure remote host. Fetch cannot follow redirects. */
65
- export function sessionServiceOrigin(raw) {
66
- let url;
67
- try {
68
- url = new URL(raw);
69
- }
70
- catch {
71
- throw new UsageError('--url must be an HTTPS or loopback HTTP service origin.');
72
- }
73
- if (url.username || url.password || url.search || url.hash || url.pathname !== '/')
74
- throw new UsageError('--url must contain only the service origin, with no credentials, path, query or fragment.');
75
- if (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['127.0.0.1', '[::1]', 'localhost'].includes(url.hostname)))
76
- throw new UsageError('--url requires HTTPS except for loopback HTTP.');
77
- return url.origin;
78
- }
79
- async function parseRequest(spec, flags, positionals, options) {
80
- const value = (name) => typeof flags[name] === 'string' ? flags[name] : '';
81
- const url = sessionServiceOrigin(value('url'));
82
- const account = value('as');
83
- if (!account || account.length > 200 || /[\s:\x00-\x1f\x7f]/.test(account))
84
- throw new UsageError('--as must name the operator account.');
85
- if (Boolean(flags['token-env']) === Boolean(flags['token-file']))
86
- throw new UsageError('Choose exactly one credential source: --token-env <variable-name> or --token-file <path>.');
87
- let token;
88
- if (flags['token-env']) {
89
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value('token-env')))
90
- throw new UsageError('--token-env must name an environment variable.');
91
- token = (options.env ?? process.env)[value('token-env')] ?? '';
92
- }
93
- else
94
- token = (await readBoundedFile(value('token-file'), 8192, 'The token file')).trim();
95
- if (!token || token.length > 8192 || /[\s\x00-\x1f\x7f]/.test(token))
96
- throw new UsageError('The selected credential is empty or invalid. Set the named variable or token file.');
97
- const request = { version: 1 };
98
- if ('sessionId' in spec.inputSchema.properties) {
99
- if (positionals.length !== 1)
100
- throw new UsageError('Name exactly one session ID.');
101
- request['sessionId'] = positionals[0];
102
- }
103
- else if (positionals.length)
104
- throw new UsageError('This operation takes no positional arguments.');
105
- for (const [name, field] of Object.entries(spec.flags)) {
106
- if (flags[name] === undefined)
107
- continue;
108
- const raw = value(name);
109
- if (field.kind === 'integer' && !/^\d+$/.test(raw))
110
- throw new UsageError(`--${name} must be a whole number.`);
111
- request[field.field] = field.kind === 'integer' ? Number(raw) : field.kind === 'nullable-id' && raw === 'none' ? null : raw;
112
- }
113
- if ('prompt' in spec.inputSchema.properties) {
114
- if (Boolean(flags['file']) === Boolean(flags['stdin']))
115
- throw new UsageError('Choose exactly one prompt source: --file <path> or --stdin.');
116
- request['prompt'] = flags['file'] ? await readBoundedFile(value('file'), SESSION_PROMPT_BYTES, 'The prompt file') : await (options.readStdin ?? readStdin)();
117
- }
118
- const validated = validateSessionRequest(spec.operation, request);
119
- if (!validated.ok)
120
- throw new UsageError(validated.message);
121
- return { spec, request: validated.request, url, account, token };
122
- }
123
- async function readResponse(response) {
124
- if (!response.body)
125
- throw new Error('Missing response body');
126
- const reader = response.body.getReader();
127
- const chunks = [];
128
- let bytes = 0;
129
- try {
130
- while (true) {
131
- const chunk = await reader.read();
132
- if (chunk.done)
133
- break;
134
- bytes += chunk.value.byteLength;
135
- if (bytes > SESSION_RESPONSE_BYTES) {
136
- await reader.cancel();
137
- throw new Error('Response limit');
138
- }
139
- chunks.push(chunk.value);
140
- }
141
- }
142
- finally {
143
- reader.releaseLock();
144
- }
145
- return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks)));
146
- }
147
144
  function unconfirmed(spec, request) {
148
145
  const sessionId = 'sessionId' in request ? request.sessionId : undefined;
149
146
  return { version: 1, operation: spec.operation, ok: false, status: spec.mutation ? 'uncertain' : 'rejected',
@@ -152,42 +149,39 @@ function unconfirmed(spec, request) {
152
149
  message: spec.mutation ? 'The service response could not be confirmed. Inspect the saved session before continuing; the request was not retried.' : 'The service response could not be read. Check the service address and connection.',
153
150
  nextActions: [{ operation: sessionId ? 'show' : 'list', label: sessionId ? 'Inspect session' : 'List sessions', ...(sessionId ? { sessionId } : {}) }] };
154
151
  }
155
- async function perform(parsed, options) {
156
- const { spec, request, url, account, token } = parsed;
157
- try {
158
- const response = await (options.fetch ?? fetch)(`${url}/api/sessions/${spec.operation}`, {
159
- method: 'POST', redirect: 'manual', credentials: 'omit', signal: AbortSignal.timeout(60_000),
160
- headers: { authorization: `Bearer ${account}:${token}`, 'content-type': 'application/json', accept: 'application/json' }, body: JSON.stringify(request),
161
- });
162
- if (response.status >= 300 && response.status < 400) {
163
- await response.body?.cancel();
164
- return unconfirmed(spec, request);
165
- }
166
- // This marker belongs to the owner boundary. A bare gateway 503 or 404
167
- // cannot prove that an upstream mutation was never accepted.
168
- if ([401, 403, 404, 503].includes(response.status) && response.headers.get('x-standing-orders-session-delivery') === 'not-sent') {
169
- await response.body?.cancel();
170
- const reason = response.status === 401 ? 'unauthenticated' : response.status === 403 ? 'forbidden' : response.status === 404 ? 'session-unsupported' : 'session-unavailable';
171
- return { version: 1, operation: spec.operation, ok: false, status: 'rejected', delivery: 'not-sent', retry: spec.mutation ? 'never' : 'safe-read', reason,
172
- message: response.status === 401 || response.status === 403 ? 'The service refused these operator credentials. Check the account and its access.' : 'This service does not currently support the requested session operation. Check its installed build and readiness.', nextActions: [] };
173
- }
174
- const payload = await readResponse(response);
175
- if (!isSessionResponse(payload, spec.operation) || (payload.ok && !response.ok))
176
- return unconfirmed(spec, request);
177
- if ('sessionId' in request && payload.result?.session && payload.result.session.id !== request.sessionId)
178
- return unconfirmed(spec, request);
179
- if (payload.ok && 'key' in request && payload.result?.receipt?.key !== request.key)
180
- return unconfirmed(spec, request);
181
- if (payload.ok && 'expectedThreadId' in request && request.expectedThreadId !== null && payload.result?.session?.nativeThreadId !== request.expectedThreadId)
152
+ /** One request over /api/cli, never retried. Only an answer whose identity matches the request counts as delivered. */
153
+ async function perform(spec, request, profile, options) {
154
+ const files = typeof request.prompt === 'string' ? { [SESSION_PROMPT_FILE]: request.prompt } : {};
155
+ const outcome = await postCli(profile.origin, profile.token, { argv: sessionArgv(spec, request), files }, options);
156
+ if (outcome.kind === 'unconfirmed')
157
+ return unconfirmed(spec, request);
158
+ if (outcome.kind === 'refused') {
159
+ // /api/cli refuses before it runs anything, except a command that stopped on the server: that one may have acted.
160
+ if (outcome.status >= 500 && outcome.status !== 501 && outcome.status !== 503)
182
161
  return unconfirmed(spec, request);
183
- return payload;
162
+ return { version: 1, operation: spec.operation, ok: false, status: 'rejected', delivery: 'not-sent', retry: spec.mutation ? 'never' : 'safe-read',
163
+ reason: /^[a-z][a-z0-9-]*$/.test(outcome.code) ? outcome.code : 'refused', message: outcome.message, nextActions: [] };
164
+ }
165
+ let payload;
166
+ try {
167
+ payload = JSON.parse(outcome.answer.stdout);
184
168
  }
185
169
  catch {
186
170
  return unconfirmed(spec, request);
187
171
  }
172
+ if (!isSessionResponse(payload, spec.operation))
173
+ return unconfirmed(spec, request);
174
+ if ('sessionId' in request && payload.result?.session && payload.result.session.id !== request.sessionId)
175
+ return unconfirmed(spec, request);
176
+ if (payload.ok && 'key' in request && payload.result?.receipt?.key !== request.key)
177
+ return unconfirmed(spec, request);
178
+ if (payload.ok && 'expectedThreadId' in request && request.expectedThreadId !== null && payload.result?.session?.nativeThreadId !== request.expectedThreadId)
179
+ return unconfirmed(spec, request);
180
+ return payload;
188
181
  }
189
182
  const terminalText = (value) => value.replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '');
190
- function render(response) {
183
+ /** The plain-text answer, the same on either side of the transport. */
184
+ export function renderSessionResponse(response) {
191
185
  const result = response.result;
192
186
  const lines = response.ok && response.operation === 'show' && result?.brief ? [] : [response.message];
193
187
  for (const session of result?.sessions ?? [])
@@ -210,6 +204,8 @@ function render(response) {
210
204
  lines.push(`${action.label}: ${action.operation === 'open-ui' ? 'open the coding workspace' : `toolroll session ${action.operation}${action.sessionId ? ` ${action.sessionId}` : ''}`}`);
211
205
  return terminalText(lines.join('\n'));
212
206
  }
207
+ /** 0 delivered · 1 unknown (inspect first) · 3 refused. */
208
+ export const sessionExitCode = (response) => response.ok ? 0 : response.status === 'uncertain' || response.reason === 'service-unavailable' ? 1 : 3;
213
209
  export async function runSessionCommand(argv, write, options = {}) {
214
210
  const json = argv.includes('--json');
215
211
  const stderr = options.stderr ?? (line => process.stderr.write(`${line}\n`));
@@ -236,49 +232,40 @@ export async function runSessionCommand(argv, write, options = {}) {
236
232
  write(json ? envelopeJson({ ok: true, command, ...sessionCapabilities() }) : help());
237
233
  return 0;
238
234
  }
239
- const spec = sessionDescriptor(operation);
240
- if (!spec)
241
- return fail(`Unknown session operation. Use: ${SESSION_CLI_ACTIONS.join(', ')}.`);
242
- const allowed = sessionCliFlags(spec);
243
- const flags = {};
244
- const positionals = [];
245
- for (let index = 1; index < argv.length; index++) {
246
- const argument = argv[index];
247
- if (!argument.startsWith('-')) {
248
- positionals.push(argument);
249
- continue;
250
- }
251
- const name = argument === '-h' ? 'help' : argument.startsWith('--') ? argument.slice(2) : '';
252
- if (!Object.hasOwn(allowed, name))
253
- return fail(`Unknown option ${argument} for session ${operation}.`);
254
- if (Object.hasOwn(flags, name))
255
- return fail(`Option --${name} was repeated.`);
256
- if (allowed[name] === 'flag')
257
- flags[name] = true;
258
- else {
259
- const value = argv[++index];
260
- if (!value || value.startsWith('-'))
261
- return fail(`--${name} requires a value.`);
262
- flags[name] = value;
263
- }
264
- }
235
+ const parsed = parseSessionArguments(argv, 'client');
236
+ if ('problem' in parsed)
237
+ return fail(parsed.problem);
238
+ const { spec, flags } = parsed;
265
239
  if (flags['help']) {
266
240
  write(json ? envelopeJson({ ok: true, command, descriptor: spec, help: help(spec) }) : help(spec));
267
241
  return 0;
268
242
  }
269
- let parsed;
243
+ let request;
244
+ let profile;
270
245
  try {
271
- parsed = await parseRequest(spec, flags, positionals, options);
246
+ let prompt;
247
+ if ('prompt' in spec.inputSchema.properties) {
248
+ if (Boolean(flags['file']) === Boolean(flags['stdin']))
249
+ throw new UsageError('Choose exactly one prompt source: --file <path> or --stdin.');
250
+ prompt = typeof flags['file'] === 'string' ? await readBoundedFile(flags['file'], SESSION_PROMPT_BYTES, 'The prompt file') : await (options.readStdin ?? readStdin)();
251
+ }
252
+ const validated = sessionRequestOf(parsed, prompt);
253
+ if (!validated.ok)
254
+ throw new UsageError(validated.message);
255
+ request = validated.request;
256
+ profile = centralProfile(options, typeof flags['profile'] === 'string' ? flags['profile'] : undefined);
272
257
  }
273
258
  catch (error) {
274
- return fail(error instanceof UsageError ? error.message : 'The session request could not be prepared. Check the prompt and credential source.');
259
+ return fail(error instanceof UsageError ? error.message : 'The session request could not be prepared. Check the prompt and saved connection.');
275
260
  }
276
- const response = await perform(parsed, options);
261
+ if (profile === null || !profile.apiToken)
262
+ return fail(SESSION_TOKEN_REQUIRED);
263
+ const response = await perform(spec, request, profile, options);
277
264
  if (json)
278
- write(envelopeJson({ ...response, command, ...('key' in parsed.request ? { key: parsed.request.key } : {}) }));
265
+ write(envelopeJson({ ...response, command, ...('key' in request ? { key: request.key } : {}) }));
279
266
  else if (response.ok)
280
- write(render(response));
267
+ write(renderSessionResponse(response));
281
268
  else
282
- stderr(render(response));
283
- return response.ok ? 0 : response.status === 'uncertain' || response.reason === 'service-unavailable' ? 1 : 3;
269
+ stderr(renderSessionResponse(response));
270
+ return sessionExitCode(response);
284
271
  }
@@ -114,8 +114,8 @@ export function isSessionResponse(value, operation) {
114
114
  return true;
115
115
  }
116
116
  export function sessionCapabilities() {
117
- return { version: SESSION_PROTOCOL_VERSION, transport: 'POST /api/sessions/<operation>', authentication: 'operator account: Authorization Bearer name:token',
118
- authority: 'Operator credentials are required for session reads and controls. External coordinator credentials have no session access.',
117
+ return { version: SESSION_PROTOCOL_VERSION, transport: 'POST /api/cli with argv session <operation> (the prompt travels as the request\'s one file)', authentication: 'API token: Authorization Bearer so_… (toolroll tokens create, saved by toolroll connect)',
118
+ authority: 'An instance operator\'s API token without a project limit reads sessions; changing one needs an act token. Lead and coordinator credentials have no session access.',
119
119
  operations: SESSION_DESCRIPTORS, brief: { supported: true, operation: 'show', defaultView: 'brief', source: 'Saved state and labeled agent reports, pinned to a revision; no additional model call.' },
120
120
  unsupported: ['events', 'review', 'continue'], limits: { promptBytes: SESSION_PROMPT_BYTES, requestBytes: SESSION_REQUEST_BYTES, responseBytes: SESSION_RESPONSE_BYTES },
121
121
  retry: 'Never automatically retry a mutation. Inspect saved state after a lost response; keep the original key.' };
@@ -0,0 +1,19 @@
1
+ import type { Store } from './store.js';
2
+ import type { CodingWorkspace } from './coding-workspace.js';
3
+ import { type Principal } from './operate-remote.js';
4
+ /**
5
+ * The server half of `toolroll session …` over `POST /api/cli` (D7, replacing the separate /api/sessions transport and
6
+ * its password sign-in). /api/cli has already proved the API token and charged its budget; this binds the token's
7
+ * person to the existing session owner, which rechecks the account and project on every operation. Native sessions
8
+ * stay installation-wide operator authority: only an instance operator's act token, limited to no projects, may
9
+ * change one. Every request is in the action ledger like any remote command, and every answer is one session
10
+ * envelope: a mutation whose outcome is unknown says so, and is never retried here.
11
+ */
12
+ export type SessionRunner = (principal: Principal, argv: readonly string[], files: Readonly<Record<string, string>>, write: (line: string) => void) => Promise<number>;
13
+ export declare function createSessionRunner(options: {
14
+ store: Store;
15
+ workspace: CodingWorkspace | null;
16
+ projects: () => readonly string[];
17
+ projectAllowed: (repo: string) => boolean;
18
+ clock?: () => Date;
19
+ }): SessionRunner;
@@ -0,0 +1,86 @@
1
+ import { realpathSync } from 'node:fs';
2
+ import { envelopeJson } from './envelope.js';
3
+ import { REMOTE_MESSAGES, reproveRemote } from './operate-remote.js';
4
+ import { SESSION_RESPONSE_BYTES, isSessionResponse } from './session-contract.js';
5
+ import { parseSessionArguments, renderSessionResponse, sessionExitCode, sessionRequestOf } from './session-cli.js';
6
+ import { SessionService } from './session-service.js';
7
+ export function createSessionRunner(options) {
8
+ const { store } = options;
9
+ const clock = options.clock ?? (() => new Date());
10
+ const authorized = (actor, repo) => {
11
+ const account = store.accountOf(actor.name);
12
+ return account !== null && account.revokedAt === null && account.generation === actor.generation && store.isInstanceOperator(actor.name)
13
+ && (repo === undefined || (options.projectAllowed(repo) && options.projects().includes(repo)));
14
+ };
15
+ const service = options.workspace === null || store.isDemo() ? null : new SessionService({ workspace: options.workspace, authorized,
16
+ project: (actor, input) => {
17
+ const path = realpathSync(input);
18
+ if (!authorized(actor, path) || !options.projects().includes(path))
19
+ throw Error('Choose a project already admitted to this installation.');
20
+ return path;
21
+ },
22
+ });
23
+ return async (principal, argv, files, write) => {
24
+ const json = argv.includes('--json');
25
+ const parsed = parseSessionArguments(argv.slice(1), 'server');
26
+ const operation = 'problem' in parsed ? null : parsed.spec.operation;
27
+ const command = `session ${operation ?? '(unknown)'}`;
28
+ let tokenName = null;
29
+ const record = (outcome, repo, why) => {
30
+ store.recordAction({ at: clock().toISOString(), actor: principal.account, repo, taskId: null, runId: null, action: `remote command: ${command}`, outcome, source: 'api',
31
+ detail: `token ${tokenName ?? principal.tokenId}${why === null ? '' : ` · ${why}`}` });
32
+ };
33
+ const answer = (response) => {
34
+ write(json ? envelopeJson({ ...response, command }) : renderSessionResponse(response));
35
+ return sessionExitCode(response);
36
+ };
37
+ const refuse = (spec, reason, message) => {
38
+ record('refused', null, reason);
39
+ if (spec === null) {
40
+ write(json ? envelopeJson({ ok: false, command, reason, message }) : message);
41
+ return 2;
42
+ }
43
+ return answer({ version: 1, operation: spec.operation, ok: false, status: 'rejected', delivery: 'not-sent', retry: spec.mutation ? 'never' : 'safe-read', reason, message, nextActions: [] });
44
+ };
45
+ const proved = reproveRemote(store, principal, clock());
46
+ if (!proved.ok)
47
+ return refuse('problem' in parsed ? null : parsed.spec, 'unauthenticated', REMOTE_MESSAGES.stale);
48
+ tokenName = proved.tokenName;
49
+ if ('problem' in parsed)
50
+ return refuse(null, 'usage', parsed.problem);
51
+ const { spec } = parsed;
52
+ if (spec.mutation && proved.scope !== 'act')
53
+ return refuse(spec, 'read-only', REMOTE_MESSAGES.read);
54
+ // A session reaches every project this installation admits: a project-limited token never stands for that.
55
+ if (principal.projects !== null || !store.isInstanceOperator(principal.account))
56
+ return refuse(spec, 'all-projects', 'Native sessions need instance operator access to every project. Use a token without a project limit.');
57
+ const file = parsed.flags['file'];
58
+ if (file !== undefined && (typeof file !== 'string' || !Object.hasOwn(files, file)))
59
+ return refuse(spec, 'usage', REMOTE_MESSAGES.files);
60
+ const validated = sessionRequestOf(parsed, typeof file === 'string' ? files[file] : undefined);
61
+ if (!validated.ok)
62
+ return refuse(spec, 'invalid-request', validated.message);
63
+ if (service === null)
64
+ return refuse(spec, 'session-unavailable', 'The session owner is not ready.');
65
+ const repo = 'project' in validated.request && spec.operation === 'start' ? validated.request.project : null;
66
+ record('requested', repo, null);
67
+ const uncertain = (reason, message) => {
68
+ const sessionId = 'sessionId' in validated.request ? validated.request.sessionId : undefined;
69
+ return { version: 1, operation: spec.operation, ok: false, status: 'uncertain', delivery: 'unknown', retry: 'inspect-first', reason, message,
70
+ nextActions: [{ operation: sessionId ? 'show' : 'list', label: sessionId ? 'Inspect session' : 'Inspect saved sessions', ...(sessionId ? { sessionId } : {}) }] };
71
+ };
72
+ let reply;
73
+ try {
74
+ reply = await service.execute({ name: principal.account, generation: principal.generation }, spec.operation, validated.request);
75
+ if (!isSessionResponse(reply, spec.operation))
76
+ throw new Error('Invalid owner response');
77
+ if (Buffer.byteLength(JSON.stringify(reply)) > SESSION_RESPONSE_BYTES)
78
+ reply = uncertain('response-too-large', 'The owner response is too large to deliver. Inspect saved activity before continuing; this request was not retried.');
79
+ }
80
+ catch {
81
+ reply = uncertain('unconfirmed-response', 'The owner response could not be confirmed. Inspect saved activity before continuing; this request was not retried.');
82
+ }
83
+ record(reply.ok ? 'done' : reply.status === 'uncertain' ? 'failed' : 'refused', repo, reply.ok ? null : reply.reason ?? null);
84
+ return answer(reply);
85
+ };
86
+ }