@remits/remits-cli 0.1.159 → 0.1.160

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.
package/index.js CHANGED
@@ -12,10 +12,10 @@
12
12
  - L4591 Auth And Component Staging
13
13
  - L5223 Component Summaries, Status, And Sync Gates
14
14
  - L7547 Branches, Promotion, Commit, And Test Runs
15
- - L9790 Tokens, Tools, Verification, And Config
16
- - L11307 Service Dashboard And WebSocket Listener
17
- - L13806 Agent And Ticket Workflows
18
- - L17982 Help, Auto Update, And Command Dispatch
15
+ - L9791 Tokens, Tools, Verification, And Config
16
+ - L11308 Service Dashboard And WebSocket Listener
17
+ - L13808 Agent And Ticket Workflows
18
+ - L18069 Help, Auto Update, And Command Dispatch
19
19
  */
20
20
 
21
21
  /*
@@ -9144,6 +9144,7 @@ function watchWebsocket(baseUrl, topicId, taskId, options = {}) {
9144
9144
  const emit = options.stderr ? console.error : console.log;
9145
9145
  const client = new Client({
9146
9146
  brokerURL: webSocketUrl(baseUrl),
9147
+ connectHeaders: { 'X-Auth-Token': options.token || '' },
9147
9148
  reconnectDelay: 3000,
9148
9149
  heartbeatIncoming: 10000,
9149
9150
  heartbeatOutgoing: 10000,
@@ -9444,7 +9445,7 @@ async function testCommand(flags) {
9444
9445
  if (flags.watch !== 'false') {
9445
9446
  const topic = session.websocketTopic || start.websocketTopic || (session.user && String(session.user.uuid || '').replace(/-/g, ''));
9446
9447
  if (topic) {
9447
- stopWs = watchWebsocket(baseUrl, topic, start.shardTaskIds || start.taskId, { stderr: jsonOutput, verbose: flagEnabled(flags.verbose) });
9448
+ stopWs = watchWebsocket(baseUrl, topic, start.shardTaskIds || start.taskId, { stderr: jsonOutput, verbose: flagEnabled(flags.verbose), token: session.token });
9448
9449
  }
9449
9450
  }
9450
9451
 
@@ -13548,6 +13549,7 @@ function createManagedWebsocketClient(baseUrl, urlSessions) {
13548
13549
  },
13549
13550
  debug: () => {},
13550
13551
  beforeConnect: () => {
13552
+ client.connectHeaders['X-Remits-Tokens'] = JSON.stringify((managedClient.urlSessions || []).map((entry) => entry.token).filter(Boolean));
13551
13553
  managedClient.lastConnectAttemptAt = Date.now();
13552
13554
  ensureRuntimeWebsocketEntry(normalizedBaseUrl);
13553
13555
  runtimeState.websocket[normalizedBaseUrl] = {
@@ -16717,6 +16719,91 @@ function describeRunLocation(record) {
16717
16719
  return parts.join(' ');
16718
16720
  }
16719
16721
 
16722
+ function usageListFlag(value) {
16723
+ if (value === undefined || value === null || value === true) return undefined;
16724
+ const items = String(value).split(',').map((item) => item.trim()).filter(Boolean);
16725
+ return items.length ? items : undefined;
16726
+ }
16727
+
16728
+ async function usageCommand(flags) {
16729
+ const cwd = process.cwd();
16730
+ ensureLocalState(cwd);
16731
+ const sessionContext = resolveSessionContext(cwd, flags);
16732
+ const { session, accountId } = sessionContext;
16733
+ const baseUrl = flags['base-url'] || session.baseUrl || DEFAULT_BASE_URL;
16734
+ const api = buildAxios(baseUrl, session.token, 120000);
16735
+ const query = {
16736
+ from: flags.from,
16737
+ to: flags.to,
16738
+ timezone: flags.timezone || flags.tz,
16739
+ by: flags.by,
16740
+ groupBy: usageListFlag(flags['group-by'] || flags.groupBy),
16741
+ capabilities: usageListFlag(flags.capabilities || flags.capability),
16742
+ lanes: usageListFlag(flags.lanes || flags.lane),
16743
+ accountId: flags['target-account-id'] || flags.targetAccountId,
16744
+ includeDescendants: flagEnabled(flags['include-descendants']) || undefined,
16745
+ includeSuites: flagEnabled(flags['include-suites']) || undefined,
16746
+ includeMocked: flagEnabled(flags['include-mocked']) || undefined,
16747
+ allocateShared: flagEnabled(flags['allocate-shared']) || undefined,
16748
+ scope: flagEnabled(flags.platform) ? 'platform' : undefined
16749
+ };
16750
+ Object.keys(query).forEach((key) => query[key] === undefined && delete query[key]);
16751
+ const response = await loggedPost(api, cwd, '/cli/usage', {
16752
+ token: session.token,
16753
+ accountId,
16754
+ dataMode: resolveDataMode(flags, session),
16755
+ query
16756
+ }).then((r) => r.data);
16757
+
16758
+ if (!response.success) throw new Error(response.message || 'Usage report failed');
16759
+ if (flagEnabled(flags.json)) {
16760
+ console.log(JSON.stringify(response, null, 2));
16761
+ return response;
16762
+ }
16763
+ printSessionResolutionWarning(sessionContext);
16764
+ printResolvedBaseUrl(baseUrl);
16765
+ printUsageSummary(response);
16766
+ return response;
16767
+ }
16768
+
16769
+ function formatUsageCurrencies(row) {
16770
+ const entries = Object.entries(row.costByCurrency || { USD: row.cost || 0 });
16771
+ return entries.length ? entries.map(([currency, value]) => currency + ' ' + Number(value).toFixed(4)).join(' / ') : 'USD 0.0000';
16772
+ }
16773
+
16774
+ function formatUsageCost(row) {
16775
+ const marker = row.costComplete === false ? ' (floor: ' + (row.unpricedEvents || 0) + ' unpriced)' : '';
16776
+ return formatUsageCurrencies(row) + ' ' + (row.basis || '') + marker;
16777
+ }
16778
+
16779
+ function printUsageSummary(report) {
16780
+ const selector = report.selector || {};
16781
+ console.log('Usage ' + selector.from + ' .. ' + selector.to + ' (' + (selector.timezone || 'UTC') + ', by ' + selector.by + ')');
16782
+ console.log('Total: ' + formatUsageCost(report));
16783
+ const rows = report.rows || [];
16784
+ const names = report.accountNames || {};
16785
+ rows.forEach((row) => {
16786
+ const who = row.accountId !== undefined ? ' account ' + (row.accountId === null ? 'platform' : (names[row.accountId] || row.accountId)) : '';
16787
+ const sku = row.sku !== undefined ? ' [' + (row.sku || '-') + ']' : '';
16788
+ const quantities = Object.entries(row.quantities || {}).map(([unit, value]) => unit + '=' + value).join(' ');
16789
+ console.log(' ' + row.period + ' ' + row.capability + who + sku + ' ' + formatUsageCost(row) + (quantities ? ' ' + quantities : ''));
16790
+ });
16791
+ const excluded = report.excluded || {};
16792
+ const excludedKeys = Object.keys(excluded);
16793
+ if (excludedKeys.length) {
16794
+ console.log('Excluded by the filters: ' + excludedKeys.map((key) => key + ' ' + (excluded[key].events || 0) + ' ops ' + formatUsageCurrencies(excluded[key])).join(', '));
16795
+ }
16796
+ const coverage = report.coverage || {};
16797
+ const partial = Object.keys(coverage).filter((cap) => coverage[cap].partial || coverage[cap].measured === false);
16798
+ if (partial.length) {
16799
+ console.log('Coverage: ' + partial.map((cap) => cap + (coverage[cap].measured === false ? ' not measured yet' : ' from ' + coverage[cap].coverageFrom)).join(', '));
16800
+ }
16801
+ if (report.invoiced && report.invoiced.available) {
16802
+ console.log('Invoiced (allocated provider bill) in range: ' + formatUsageCurrencies(report.invoiced));
16803
+ if (report.invoiced.message) console.log(report.invoiced.message);
16804
+ }
16805
+ }
16806
+
16720
16807
  async function activityInspectCommand(flags) {
16721
16808
  const cwd = process.cwd();
16722
16809
  ensureLocalState(cwd);
@@ -18597,6 +18684,7 @@ async function main() {
18597
18684
  console.log(' remits-cli agent map [--account-ids 1,4] [--json] # who is EDITING what, where, right now');
18598
18685
  console.log(' remits-cli activity inspect [--account-id ID] [--scope self|children|related] [--workspace NAME] [--workstream ID] [--user-email E] [--summary|--compact] [--json]');
18599
18686
  console.log(' remits-cli workstream status [--workstream ID|--workspace NAME] [--json]');
18687
+ console.log(' remits-cli usage --from DATE [--to DATE] [--by hour|day|month] [--group-by account,sku] [--capabilities ai,document_store] [--json]');
18600
18688
  console.log(' remits-cli agent release # stop receiving tickets now');
18601
18689
  console.log(' remits-cli start [--foreground true] [--port 8787] # human dashboard in the browser');
18602
18690
  console.log(' remits-cli stop');
@@ -18859,6 +18947,23 @@ async function main() {
18859
18947
  throw new Error('Unknown activity subcommand: ' + subcommand);
18860
18948
  }
18861
18949
 
18950
+ if (command === 'usage') {
18951
+ if (wantsHelp) {
18952
+ console.log('Usage: remits-cli usage --from DATE [--to DATE] [--timezone ZONE] [--by hour|day|month]');
18953
+ console.log(' [--group-by account,sku,segment,provider] [--capabilities ai,document_store,...]');
18954
+ console.log(' [--target-account-id ID] [--include-descendants] [--lanes prod,test|all]');
18955
+ console.log(' [--include-suites] [--include-mocked] [--allocate-shared] [--platform] [--json]');
18956
+ console.log('');
18957
+ console.log('What the account used and cost, by capability: the same answer as front-stage usage([...]).');
18958
+ console.log('Defaults: prod lane, nothing a Test suite caused, nothing mocked; "Excluded" says what that left out.');
18959
+ console.log('Each figure has a basis (invoiced > reported > estimated > metered); a cost marked "floor" has');
18960
+ console.log('unpriced operations. --platform (platform administrators) reports every account and platform usage.');
18961
+ return;
18962
+ }
18963
+ await usageCommand(args);
18964
+ return;
18965
+ }
18966
+
18862
18967
  if (command === 'evidence') {
18863
18968
  if (wantsHelp) {
18864
18969
  console.log('Usage: remits-cli evidence [--limit N] [--json]');
package/lib/local-ai.js CHANGED
@@ -66,8 +66,60 @@ function assertTextRequest(request) {
66
66
  }
67
67
  }
68
68
 
69
- function promptFor(packet) {
70
- return [
69
+ // The offered tools and choice rules, derived exactly as LocalAiBridge.decision derives them.
70
+ function offeredTools(request = {}) {
71
+ const legacy = !request.tools?.length && !!request.functions?.length;
72
+ const names = [...new Set((request.tools?.length ? request.tools : request.functions || [])
73
+ .map((tool) => tool?.function?.name ?? tool?.name).filter((name) => typeof name === 'string' && name))];
74
+ const choice = legacy ? request.function_call : request.tool_choice;
75
+ const required = choice && typeof choice === 'object' ? (legacy ? choice.name : choice.function?.name) || null : null;
76
+ return { legacy, names, choice, required };
77
+ }
78
+
79
+ // The transport envelope for THIS packet. Only names and counts are constrained: arguments stay a plain
80
+ // object because tool schemas are repaired forgivingly server-side, and the server still validates all.
81
+ function decisionSchema(request) {
82
+ const { legacy, names, choice, required } = offeredTools(request);
83
+ const allowed = required ? names.filter((name) => name === required) : names;
84
+ let toolCalls;
85
+ if (!allowed.length || choice === 'none') toolCalls = { type: 'array', maxItems: 0 };
86
+ else {
87
+ toolCalls = { type: 'array', items: { type: 'object', additionalProperties: false, required: ['name', 'arguments'],
88
+ properties: { name: { type: 'string', enum: allowed }, arguments: { type: 'object' } } } };
89
+ if (choice === 'required' || required) toolCalls.minItems = 1;
90
+ if (legacy || request.parallel_tool_calls === false) toolCalls.maxItems = 1;
91
+ }
92
+ return { type: 'object', additionalProperties: false, required: ['content', 'toolCalls'],
93
+ properties: { content: { type: ['string', 'null'] }, toolCalls } };
94
+ }
95
+
96
+ // An exact continuation of the previous packet: appended messages, a few same-position replacements
97
+ // (e.g. a regenerated runtime-context message) and changed top-level fields. Null when history was
98
+ // pruned or reordered, when more than MAX_REPLACED messages changed, or when it would not be smaller.
99
+ const MAX_REPLACED = 2;
100
+ function continuation(base, request) {
101
+ if (!base?.request || !Array.isArray(base.request.messages) || !Array.isArray(request?.messages)) return null;
102
+ const previous = base.request.messages;
103
+ if (request.messages.length <= previous.length) return null;
104
+ const replacedMessages = {};
105
+ for (let i = 0; i < previous.length; i++) {
106
+ if (JSON.stringify(previous[i]) === JSON.stringify(request.messages[i])) continue;
107
+ if (previous[i]?.role !== request.messages[i]?.role || Object.keys(replacedMessages).length >= MAX_REPLACED) return null;
108
+ replacedMessages[i] = request.messages[i];
109
+ }
110
+ const changedFields = {};
111
+ for (const key of Object.keys(request)) {
112
+ if (key !== 'messages' && JSON.stringify(request[key]) !== JSON.stringify(base.request[key])) changedFields[key] = request[key];
113
+ }
114
+ const removedFields = Object.keys(base.request).filter((key) => key !== 'messages' && !Object.hasOwn(request, key));
115
+ const delta = { appendedMessages: request.messages.slice(previous.length), changedFields, removedFields };
116
+ if (Object.keys(replacedMessages).length) delta.replacedMessages = replacedMessages;
117
+ return JSON.stringify(delta).length < JSON.stringify(request).length ? delta : null;
118
+ }
119
+
120
+ function promptFor(packet, { agent, base } = {}) {
121
+ const delta = base ? continuation(base, packet.request) : null;
122
+ const lines = [
71
123
  'You are the receiving model for a Remits AI invocation. Produce ONE next assistant decision.',
72
124
  'The JSON packet below is the exact outgoing model request, including system instructions, messages,',
73
125
  'tool definitions, tool choice, and response-format requirements. Treat it as authoritative for this turn.',
@@ -77,16 +129,31 @@ function promptFor(packet) {
77
129
  'send you a new packet with the results in this SAME conversation. Never fabricate a tool result.',
78
130
  'Only call tools offered in THIS packet. Respect tool_choice and parallel_tool_calls.',
79
131
  'Earlier packets may have been pruned or corrected. Do not use omitted content as authoritative input.',
80
- 'Return ONLY one JSON object: {"content":"assistant reply or null","toolCalls":[{"name":"offered name","arguments":{}}]}.',
132
+ 'If a decision you returned earlier is not reflected in this request, Remits did not accept or execute it.'
133
+ ];
134
+ if (agent === 'claude') {
135
+ lines.push('Deliver the decision by calling the StructuredOutput tool exactly once with',
136
+ '{"content":"assistant reply or null","toolCalls":[{"name":"offered name","arguments":{}}]}.',
137
+ 'StructuredOutput is the Remits transport for this decision, not a workflow tool. Prose, markdown,',
138
+ 'or fenced "tool call" examples outside StructuredOutput are discarded and never executed.');
139
+ } else {
140
+ lines.push('Return ONLY one JSON object: {"content":"assistant reply or null","toolCalls":[{"name":"offered name","arguments":{}}]}.');
141
+ }
142
+ lines.push(
81
143
  'Use actual JSON null for absent content and [] for no tool calls. If a response_format asks for JSON,',
82
144
  'put that formatted response as a JSON-encoded STRING in content, inside this transport envelope.',
83
145
  'If the requested reply is empty, return {"content":"","toolCalls":[]} rather than omitting the envelope.',
84
146
  'For a content/XML tool channel, put the required tool markup in content and leave toolCalls empty.',
85
- `Remits sessionId: ${packet.sessionId}; requestId: ${packet.requestId}.`,
86
- '<remits_request>',
87
- JSON.stringify(packet.request),
88
- '</remits_request>'
89
- ].join('\n');
147
+ `Remits sessionId: ${packet.sessionId}; requestId: ${packet.requestId}.`);
148
+ const full = ['<remits_request>', JSON.stringify(packet.request), '</remits_request>'];
149
+ const continued = delta && [`This request CONTINUES request ${base.requestId}, already in this conversation. Reconstruct the exact`,
150
+ 'outgoing request: take that request as you reconstructed it, replace the messages at each replacedMessages index',
151
+ '(0-based, if present), append appendedMessages, replace each changedFields entry, and drop removedFields.',
152
+ 'Everything else in it is unchanged and still authoritative.',
153
+ '<remits_request_continuation>', JSON.stringify(delta), '</remits_request_continuation>'];
154
+ // A continuation exists only to shrink the turn; short histories are cheaper re-sent whole.
155
+ const chosen = continued && continued.join('\n').length < full.join('\n').length ? continued : full;
156
+ return [...lines, ...chosen].join('\n');
90
157
  }
91
158
 
92
159
  function parseDecision(text) {
@@ -94,6 +161,10 @@ function parseDecision(text) {
94
161
  let result;
95
162
  try { result = JSON.parse(clean); }
96
163
  catch (_) { throw new Error('Local agent did not return the decision JSON envelope'); }
164
+ return validateDecision(result);
165
+ }
166
+
167
+ function validateDecision(result) {
97
168
  if (!result || typeof result !== 'object' || Array.isArray(result)) throw new Error('Local agent decision must be an object');
98
169
  if (result.error) return { error: String(result.error).slice(0, 4000) };
99
170
  if (result.content != null && typeof result.content !== 'string') throw new Error('Decision content must be a string or null');
@@ -108,7 +179,9 @@ function parseDecision(text) {
108
179
  }
109
180
 
110
181
  // Use explicit native IDs, never --last/--continue. Each session has an empty, stable working directory.
111
- function nativeCommand(agent, nativeSessionId, model) {
182
+ // Claude's decision is enforced natively: --json-schema offers only the synthetic StructuredOutput tool
183
+ // (verified with --tools '' and --resume), validates it, and returns it as result.structured_output.
184
+ function nativeCommand(agent, nativeSessionId, model, request = {}) {
112
185
  let args;
113
186
  if (agent === 'codex') {
114
187
  args = ['exec', '--sandbox', 'read-only', '--json', '--skip-git-repo-check'];
@@ -117,7 +190,8 @@ function nativeCommand(agent, nativeSessionId, model) {
117
190
  args.push('-');
118
191
  } else if (agent === 'claude') {
119
192
  args = ['-p', '--output-format', 'stream-json', '--verbose', '--tools', '',
120
- '--strict-mcp-config', '--disable-slash-commands', '--setting-sources', ''];
193
+ '--strict-mcp-config', '--disable-slash-commands', '--setting-sources', '',
194
+ '--json-schema', JSON.stringify(decisionSchema(request))];
121
195
  if (nativeSessionId) args.push('--resume', nativeSessionId);
122
196
  if (model) args.push('--model', model);
123
197
  } else if (agent === 'gemini') {
@@ -136,6 +210,7 @@ function consumeEvent(agent, event, state) {
136
210
  identifier(id);
137
211
  if (state.nativeSessionId && state.nativeSessionId !== id) throw new Error('Native agent changed session ID; refusing to fork the Remits session');
138
212
  state.nativeSessionId = id;
213
+ state.started = true;
139
214
  }
140
215
  if (event.model) state.model = String(event.model);
141
216
  if (agent === 'codex') {
@@ -143,6 +218,16 @@ function consumeEvent(agent, event, state) {
143
218
  if (event.type === 'turn.completed') state.completed = true;
144
219
  if (event.type === 'turn.failed' || event.type === 'error') state.error = event.error?.message || event.message || 'Codex turn failed';
145
220
  } else if (agent === 'claude') {
221
+ if (event.type === 'system' && event.subtype === 'init') {
222
+ if (event.claude_code_version) state.version = String(event.claude_code_version).slice(0, 40);
223
+ if (Array.isArray(event.tools) && !event.tools.includes('StructuredOutput')) {
224
+ const error = new Error(`Claude Code ${state.version || ''} did not offer StructuredOutput for --json-schema; upgrade Claude Code. No decision was requested.`);
225
+ error.notStarted = true;
226
+ throw error;
227
+ }
228
+ }
229
+ // Native history was summarized: the next turn must not rely on an earlier packet being verbatim.
230
+ if (event.type === 'system' && event.subtype === 'compact_boundary') state.compacted = true;
146
231
  if (event.type === 'assistant') {
147
232
  if (event.message?.model) state.model = event.message.model;
148
233
  const content = (event.message?.content || []).filter((part) => part.type === 'text').map((part) => part.text).join('');
@@ -150,8 +235,16 @@ function consumeEvent(agent, event, state) {
150
235
  }
151
236
  if (event.type === 'result') {
152
237
  state.completed = true;
153
- state.text = event.structured_output ? JSON.stringify(event.structured_output) : (event.result || state.text);
154
- if (event.is_error || (event.subtype && event.subtype !== 'success')) state.error = event.result || (event.errors || []).join('; ') || event.subtype;
238
+ // Only the schema-validated structured result is a decision; result text is never parsed for one.
239
+ if (event.structured_output != null) state.decision = event.structured_output;
240
+ state.text = event.result || state.text;
241
+ state.outcome = { subtype: event.subtype, turns: event.num_turns, stop: event.stop_reason };
242
+ if (event.is_error || (event.subtype && event.subtype !== 'success')) {
243
+ state.error = (event.errors || []).join('; ') || (event.is_error && event.result) || event.subtype;
244
+ if (event.subtype === 'error_max_structured_output_retries') {
245
+ state.error = 'Claude exhausted its structured-output retries without a valid decision envelope';
246
+ }
247
+ }
155
248
  }
156
249
  } else if (agent === 'gemini') {
157
250
  if (event.type === 'message' && event.role === 'assistant') state.text = (state.text || '') + (event.content || '');
@@ -162,9 +255,14 @@ function consumeEvent(agent, event, state) {
162
255
  }
163
256
  }
164
257
 
165
- function runNative({ agent, nativeSessionId, model, cwd, packet, signal, onSession, onProcess }, spawnFn = spawn) {
258
+ // Packet continuations are limited to adapters whose native history compaction is observable.
259
+ const CONTINUATION_AGENTS = new Set(['claude']);
260
+
261
+ // A rejection carries what the supervisor needs to settle the session: notStarted (no native turn
262
+ // began, so nothing is uncertain) or completedTurn (the native turn settled; only its decision failed).
263
+ function runNative({ agent, nativeSessionId, model, cwd, packet, base, signal, onSession, onProcess }, spawnFn = spawn) {
166
264
  assertTextRequest(packet.request);
167
- const command = nativeCommand(agent, nativeSessionId, model);
265
+ const command = nativeCommand(agent, nativeSessionId, model, packet.request);
168
266
  return new Promise((resolve, reject) => {
169
267
  const env = { ...process.env };
170
268
  // Authentication for Remits stays in the supervisor. Native CLIs retain their own login/environment.
@@ -175,7 +273,7 @@ function runNative({ agent, nativeSessionId, model, cwd, packet, signal, onSessi
175
273
  const state = { nativeSessionId, text: '', completed: false };
176
274
  let bytes = 0, buffer = '', stderr = '', fatal, killTimer;
177
275
  const kill = (reason) => {
178
- if (!fatal) fatal = new Error(reason);
276
+ if (!fatal) fatal = reason instanceof Error ? reason : new Error(reason);
179
277
  const send = (sig) => {
180
278
  try { process.platform === 'win32' ? child.kill(sig) : process.kill(-child.pid, sig); } catch (_) { /* exited */ }
181
279
  };
@@ -191,7 +289,7 @@ function runNative({ agent, nativeSessionId, model, cwd, packet, signal, onSessi
191
289
  const previous = state.nativeSessionId;
192
290
  consumeEvent(agent, event, state);
193
291
  if (state.nativeSessionId && state.nativeSessionId !== previous) onSession?.(state.nativeSessionId);
194
- } catch (error) { kill(error.message); }
292
+ } catch (error) { kill(error); }
195
293
  };
196
294
  child.stdout.setEncoding('utf8');
197
295
  child.stderr.setEncoding('utf8');
@@ -214,22 +312,48 @@ function runNative({ agent, nativeSessionId, model, cwd, packet, signal, onSessi
214
312
  handleLine(buffer);
215
313
  clearTimeout(killTimer);
216
314
  try { onProcess?.(null); } catch (error) { fatal = error; }
217
- if (fatal || code !== 0 || state.error || !state.completed || !state.nativeSessionId) {
218
- reject(fatal || new Error(state.error || `Native ${agent} failed (exit ${code}; completed=${state.completed}; session=${!!state.nativeSessionId}). ${stderr}`));
315
+ const settled = !fatal && code === 0 && state.completed && !!state.nativeSessionId;
316
+ const fail = (error) => {
317
+ if (!state.started) error.notStarted = true;
318
+ if (settled) error.completedTurn = true;
319
+ reject(error);
320
+ };
321
+ if (!settled || state.error) {
322
+ const option = code !== 0 && /unknown option '?(--[\w-]+)/i.exec(stderr);
323
+ fail(fatal || new Error(option ? `Installed ${agent} CLI does not support ${option[1]}; upgrade it. No native turn ran.` :
324
+ state.error ? `Native ${agent} turn failed: ${String(state.error).slice(0, 1000)}${describe(state)}` :
325
+ `Native ${agent} failed (exit ${code}; completed=${state.completed}; session=${!!state.nativeSessionId}). ${stderr}`));
219
326
  return;
220
327
  }
221
- try { resolve({ ...parseDecision(state.text), nativeSessionId: state.nativeSessionId, model: state.model || model || null }); }
222
- catch (error) { reject(error); }
328
+ try {
329
+ let decision;
330
+ if (agent === 'claude') {
331
+ if (state.decision == null) {
332
+ throw new Error('Claude completed without a structured decision: StructuredOutput was not called, so its ' +
333
+ `${String(state.text || '').length}-character reply was neither parsed nor executed${describe(state)}`);
334
+ }
335
+ decision = validateDecision(state.decision);
336
+ } else decision = parseDecision(state.text);
337
+ resolve({ ...decision, nativeSessionId: state.nativeSessionId, model: state.model || model || null, compacted: !!state.compacted });
338
+ } catch (error) { fail(error); }
223
339
  });
224
340
  try {
225
341
  onProcess?.(child.pid);
226
342
  signal?.addEventListener('abort', onAbort, { once: true });
227
343
  if (signal?.aborted) onAbort();
228
- child.stdin.end(promptFor(packet));
344
+ child.stdin.end(promptFor(packet, { agent, base: CONTINUATION_AGENTS.has(agent) ? base : null }));
229
345
  } catch (error) { kill(error.message); }
230
346
  });
231
347
  }
232
348
 
349
+ // Bounded native diagnostics: identifiers and counts only, never packet or reply content.
350
+ function describe(state) {
351
+ const parts = [state.version && 'native ' + state.version, state.model && 'model ' + state.model,
352
+ state.outcome?.subtype && 'result ' + state.outcome.subtype, state.outcome?.turns != null && 'turns ' + state.outcome.turns,
353
+ state.outcome?.stop && 'stop ' + state.outcome.stop].filter(Boolean);
354
+ return parts.length ? ` (${parts.join('; ')})` : '';
355
+ }
356
+
233
357
  class Supervisor {
234
358
  constructor({ api, dir, worker, agent, model, log = console.error, run = runNative, clock = Date.now, onStop }) {
235
359
  Object.assign(this, { api, dir, worker, agent, model, log, run, clock, onStop });
@@ -310,23 +434,27 @@ class Supervisor {
310
434
  } else {
311
435
  const cwd = path.join(this.dir, 'workspaces', sessionId);
312
436
  mkdir(cwd);
437
+ const base = this.continuationBase(session);
438
+ const prior = { state: session.state, requestId: session.requestId, nativeSessionId: session.nativeSessionId };
313
439
  cached = { packet, state: 'running' };
314
440
  write(requestFile, cached);
315
441
  Object.assign(session, { state: 'running', requestId: id, updatedAt: this.clock() });
316
442
  write(sessionFile, session);
317
443
  const timeout = setTimeout(() => task.controller.abort(), Math.max(1, packet.expiresAt - this.clock()));
318
444
  try {
319
- answer = await this.run({
320
- agent: this.agent, nativeSessionId: session.nativeSessionId, model: this.model, cwd, packet,
445
+ const { compacted, ...decision } = await this.run({
446
+ agent: this.agent, nativeSessionId: session.nativeSessionId, model: this.model, cwd, packet, base,
321
447
  signal: task.controller.signal,
322
448
  onSession: (nativeSessionId) => { session.nativeSessionId = nativeSessionId; write(sessionFile, session); },
323
449
  onProcess: (pid) => { session.pid = pid; write(sessionFile, session); }
324
450
  });
325
- session.nativeSessionId = answer.nativeSessionId;
326
- session.state = 'ready';
451
+ answer = decision;
452
+ Object.assign(session, { nativeSessionId: answer.nativeSessionId, state: 'ready', refreshPacket: !!compacted });
327
453
  this.log(`Local AI ${id}: ${this.agent} session ${session.nativeSessionId} → Remits ${sessionId}`);
328
454
  } catch (error) {
329
- session.state = 'uncertain';
455
+ if (error.notStarted) Object.assign(session, prior); // Nothing reached the native conversation.
456
+ else if (error.completedTurn) session.state = 'ready'; // Settled turn; its rejected decision is never a base.
457
+ else session.state = 'uncertain';
330
458
  answer = { error: error.message.slice(0, 4000), nativeSessionId: session.nativeSessionId };
331
459
  } finally { clearTimeout(timeout); }
332
460
  // Write the response first; after a crash it can be delivered without another inference.
@@ -352,6 +480,16 @@ class Supervisor {
352
480
  }
353
481
  }
354
482
 
483
+ // The previous packet may be sent as a prefix only when its decision was delivered from this same
484
+ // native conversation and native history has not been compacted since.
485
+ continuationBase(session) {
486
+ if (!session.nativeSessionId || !session.requestId || session.refreshPacket || session.state !== 'ready') return null;
487
+ const prior = read(path.join(this.dir, 'requests', identifier(session.requestId) + '.json'));
488
+ if (prior?.state !== 'delivered' || !prior.packet?.request || !prior.response || prior.response.error ||
489
+ prior.response.nativeSessionId !== session.nativeSessionId) return null;
490
+ return { requestId: session.requestId, request: prior.packet.request };
491
+ }
492
+
355
493
  async stop() {
356
494
  this.stopping = true;
357
495
  for (const task of this.active.values()) task.controller.abort();
@@ -462,4 +600,4 @@ async function command({ subcommand, flags, context, root, axios, Client, WebSoc
462
600
  }
463
601
 
464
602
  module.exports = { command, help, Supervisor, runNative, nativeCommand, consumeEvent, promptFor, parseDecision,
465
- assertTextRequest, stateDirectory, acquireLock, read, write, identifier };
603
+ validateDecision, decisionSchema, offeredTools, continuation, assertTextRequest, stateDirectory, acquireLock, read, write, identifier };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remits/remits-cli",
3
- "version": "0.1.159",
3
+ "version": "0.1.160",
4
4
  "description": "Local CLI for auth, component sync, and live test execution against Remits",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -29,6 +29,13 @@ and a precise diagnosis.
29
29
 
30
30
  ### The three source layers + the compile cache
31
31
 
32
+ CLI global Tools resolve staged edits in the Tool owner's account using the caller's user,
33
+ git branch and workspace, even when `--as-account` selects another execution account.
34
+ Stage the Tool in its owner repository and dependent components in their own repository with
35
+ the same workspace. Check the Tool's `componentSource` and `componentSignature` separately
36
+ from the Action's returned provenance. This requires the platform correction tracked as 68570;
37
+ older builds use the committed global Tool when it is outside the execution account's inheritance chain.
38
+
32
39
  1. **CLI staging cache (Redis, 240-min TTL).** Branch + user + account scoped overrides written by
33
40
  `remits-cli components stage`. These shadow the layers below **only during CLI/test-mode execution**
34
41
  (see "When staged overrides apply" below).
@@ -18,7 +18,8 @@ remits-cli ai serve --agent codex --base-url http://localhost:8080 --account-id
18
18
  ```
19
19
 
20
20
  Use `claude`, `gemini`, or `manual` instead of `codex`. Native executables must be installed and logged
21
- in. `--model` selects the native model; the workflow's `model` remains its configured provider target.
21
+ in. Claude decisions are enforced with Claude Code's `--json-schema` structured output; an installed
22
+ Claude Code that lacks it fails each request with an "upgrade Claude Code" error before any native turn. `--model` selects the native model; the workflow's `model` remains its configured provider target.
22
23
  `--max-concurrent 1..16` controls concurrent Remits sessions; default 1. `--worker-id` selects a named
23
24
  worker. `--role` declares a role when the user's agent permissions require one. Workstream permission
24
25
  is `ai`.
@@ -88,8 +89,11 @@ supervisor observes release and exits. No automatic migration to a different wor
88
89
 
89
90
  Missed WebSocket notifications recover through authenticated polling. Finished decisions are saved
90
91
  before submission, so an HTTP retry does not repeat inference. If the supervisor died during inference,
91
- the native outcome is uncertain: inspect the native session and use a new Remits session rather than
92
- deleting the journal and accidentally replaying a tool decision. Never run competing supervisors with
92
+ or a native turn was killed mid-way, the native outcome is uncertain: inspect the native session and use
93
+ a new Remits session rather than deleting the journal and accidentally replaying a tool decision. A native
94
+ turn that finished with an invalid decision (for Claude, a reply that never used structured output) fails
95
+ that request only; the session stays usable and the next request re-sends the full packet. A native CLI
96
+ that failed before starting a turn leaves the session as it was. Never run competing supervisors with
93
97
  the same worker ID.
94
98
 
95
99
  Local state is under `~/.remits-cli/ai/<scope hash>/`: `worker.json`, `sessions/`, `requests/`, and isolated
@@ -529,7 +529,7 @@ turns up a run that is consuming resources and should not finish — the case th
529
529
  # the usual path: you found the event in mcp_record_listing / mcp_object_activity
530
530
  remits-cli tool --account-id 21 --as-account 49 --target-account 49 --name mcp_run_action --data-mode prod --input '{"controlAction":"interrupt","eventId":19102,"reason":"runaway extraction, 45min"}'
531
531
 
532
- # or stop a run you started yourself (executionMode:'event' only)
532
+ # or stop an event-backed or managed async run you started yourself
533
533
  remits-cli tool --account-id 21 --as-account 49 --target-account 49 --name mcp_run_action --data-mode prod --input '{"controlAction":"interrupt","actionRunId":"my-run-id"}'
534
534
  ```
535
535
 
@@ -542,7 +542,7 @@ Aliases `cancel` / `stop` / `kill` all work. What you need to know before using
542
542
  - **Work already committed is NOT rolled back.** This stops further work; it does not undo what has run.
543
543
  - **Only `PENDING`/`QUEUED`/`PROCESSING` can be interrupted.** A terminal event is reported back with its
544
544
  status rather than being silently reported as "interrupted".
545
- - **Event-scoped.** A `direct` or `async` run has no Event and cannot be stopped this way.
545
+ - **Async runs also support cooperative stop** once the updated platform and System Account Tool 132 are deployed. `interruptRequested:true` means the stop was recorded; poll until the worker reports a terminal status. Legacy async runs without `runControlId`, and direct runs, cannot be stopped through this handle. An explicit server shutdown settles managed async runs as failed with `terminationReason:owner_shutdown`; inspect partial effects before any rerun.
546
546
  - Tenant-scoped: you cannot interrupt another account's event.
547
547
  - The response returns `previousStatus`, `eventStatus`, and `threadGroupingId`, so you can pivot straight
548
548
  into `mcp_performance_trace` / `mcp_system_logs` to see what it was doing when you stopped it.