@jini-ai/daemon 0.6.15-rc.2 → 0.6.15

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 (49) hide show
  1. package/dist/agent-executor/codex/home-config.js +3 -2
  2. package/dist/agent-executor/codex/home-staging.js +2 -2
  3. package/dist/agent-executor/codex/isolation.js +6 -4
  4. package/dist/agent-executor/event-translation.js +2 -2
  5. package/dist/agent-executor/events.js +64 -48
  6. package/dist/agent-executor/launch.js +30 -46
  7. package/dist/agent-executor/live-session-state.js +2 -2
  8. package/dist/agent-executor/mcp-bridge.js +5 -0
  9. package/dist/agent-executor/message-relay.js +5 -6
  10. package/dist/agent-executor/run-output.js +7 -2
  11. package/dist/close-status.js +6 -2
  12. package/dist/core/omit-keys.d.ts +7 -0
  13. package/dist/core/omit-keys.js +12 -0
  14. package/dist/core/read-only-tools.js +5 -1
  15. package/dist/core/tool-gaps.js +1 -1
  16. package/dist/delegated-result-correlation.js +2 -2
  17. package/dist/http/agents.d.ts +4 -0
  18. package/dist/http/attachments.js +13 -12
  19. package/dist/http/component-catalog.js +1 -1
  20. package/dist/http/connectors.js +7 -5
  21. package/dist/http/db-ops.js +5 -0
  22. package/dist/http/delegated-tools.js +5 -0
  23. package/dist/http/frontend-sessions.js +7 -5
  24. package/dist/http/host-tools.js +7 -5
  25. package/dist/http/memory.js +3 -1
  26. package/dist/http/model-proxy.js +13 -8
  27. package/dist/http/research-tavily.adapter.js +2 -1
  28. package/dist/http/routines.js +7 -5
  29. package/dist/http/terminals.js +5 -0
  30. package/dist/http/tool-catalog.js +1 -1
  31. package/dist/http/xai.js +10 -7
  32. package/dist/legacy-data-migration.d.ts +0 -12
  33. package/dist/legacy-data-migration.js +21 -13
  34. package/dist/remote-tool-executor.js +2 -1
  35. package/dist/routines/schedule.js +2 -1
  36. package/dist/routines/scheduler.js +6 -7
  37. package/dist/run-lifecycle.js +2 -1
  38. package/dist/store/agent-sessions/sql.js +7 -3
  39. package/dist/store/event-log/sqlite-kernel.js +5 -1
  40. package/dist/store/event-log/sqlite.js +6 -2
  41. package/dist/surface-exchanges.js +3 -3
  42. package/dist/terminal-session.js +4 -5
  43. package/dist/tool-executor.js +47 -3
  44. package/dist/tool-recovery/recovery.js +5 -3
  45. package/dist/tool-recovery/redaction.js +1 -2
  46. package/dist/tool-recovery/stack.js +5 -1
  47. package/dist/tool-result-media.js +7 -1
  48. package/dist/tool-result-surfaces.js +10 -9
  49. package/package.json +20 -15
@@ -1,5 +1,9 @@
1
1
  import { isReadOnlyTool, toolDiscoveryMetadata } from '@jini-ai/core';
2
2
  import { toolCandidates } from './tool-candidates.js';
3
+ /** ` — did you mean a, b?` for any close tool ids, else nothing. */
4
+ function didYouMean(candidates) {
5
+ return candidates.length > 0 ? ` — did you mean ${candidates.join(', ')}?` : '';
6
+ }
3
7
  /**
4
8
  * Default daemon refusal wording, replaceable by the host. Unknown registrations and writes both
5
9
  * refuse unsafe dispatch; unknown ids direct discovery because an unrestricted retry cannot run them.
@@ -11,7 +15,7 @@ export const defaultDaemonMessages = {
11
15
  toolRefusalMessage: ({ toolId }) => `tool "${toolId}" is not registered as read-only — this gateway executes only tools whose registration declares readOnly; call it through execute_delegated_tool instead`,
12
16
  unknownToolMessage: ({ toolId, candidates }) => toolId === 'check_messages'
13
17
  ? 'check_messages is an MCP-local tool; call check_messages directly, never as toolId in a delegated gateway'
14
- : `no tool "${toolId}" is registered; use search_tools${candidates.length > 0 ? ` — did you mean ${candidates.join(', ')}?` : ''}`,
18
+ : `no tool "${toolId}" is registered; use search_tools${didYouMean(candidates)}`,
15
19
  },
16
20
  };
17
21
  /** @returns A copied, attenuated principal; its roles are unchanged. */
@@ -189,7 +189,7 @@ export function createToolGapCollector(required, optional = {}) {
189
189
  const payload = event.payload;
190
190
  if (payload.type === 'text_delta')
191
191
  readDelta(payload.delta);
192
- else if (payload.type === 'tool_use')
192
+ if (payload.type === 'tool_use')
193
193
  recordToolUse(payload, event.ts);
194
194
  },
195
195
  /** Await outstanding audit writes during graceful shutdown/tests. */
@@ -1,3 +1,4 @@
1
+ import { omitKeys } from './core/omit-keys.js';
1
2
  /** Accept only the complete base64 source shape; extra fields must remain part of the digest. */
2
3
  function base64ImageSource({ source }, _options = {}) {
3
4
  if (typeof source !== 'object' || source === null)
@@ -14,8 +15,7 @@ function normalizeSourceImage({ record }, _options = {}) {
14
15
  const image = base64ImageSource({ source: record.source });
15
16
  if (!image)
16
17
  return record;
17
- const { source: _source, ...rest } = record;
18
- return { ...rest, data: image.data, mimeType: image.media_type };
18
+ return { ...omitKeys(record, ['source']), data: image.data, mimeType: image.media_type };
19
19
  }
20
20
  /** Normalize unambiguous CLI images and JSON object key order.
21
21
  * @complexity O(n + k log k) for n bytes and k keys per object; inputs are wire JSON. */
@@ -51,6 +51,10 @@ export interface AgentSummary {
51
51
  * as `true` (no known reason to warn) rather than assuming the worst.
52
52
  */
53
53
  readonly supportsTools?: boolean;
54
+ /** Project the runtime definition so picker preflight agrees with launch enforcement. */
55
+ readonly supportsNativeToolAccess?: boolean;
56
+ readonly fullCliAccessOnly?: boolean;
57
+ readonly fullCliAccessNote?: string;
54
58
  }
55
59
  export interface AgentsHttpDeps {
56
60
  /** Returns the host's cached or freshly resolved client-safe agent inventory. */
@@ -284,13 +284,12 @@ const SIGNATURE_BYTES = FTYP_COMPATIBLE_BRANDS_OFFSET + MAX_SCANNED_COMPATIBLE_B
284
284
  * production is a check nobody knows works — a fake identity states each case directly.
285
285
  */
286
286
  export function isUnchangedAttachment({ recorded, observed }, _optional = {}) {
287
- return observed.isRegularFile
288
- && observed.canonicalPath === recorded.filePath
289
- && observed.dev === recorded.dev
290
- && observed.ino === recorded.ino
291
- && observed.size === recorded.size
292
- && (recorded.mtimeMs === undefined || observed.mtimeMs === recorded.mtimeMs)
287
+ const sameFile = observed.isRegularFile && observed.canonicalPath === recorded.filePath
288
+ && observed.dev === recorded.dev && observed.ino === recorded.ino;
289
+ // A recorded timestamp must still hold; one never recorded cannot disagree.
290
+ const sameTimes = (recorded.mtimeMs === undefined || observed.mtimeMs === recorded.mtimeMs)
293
291
  && (recorded.ctimeMs === undefined || observed.ctimeMs === recorded.ctimeMs);
292
+ return sameFile && observed.size === recorded.size && sameTimes;
294
293
  }
295
294
  /**
296
295
  * Phase 1 of `claim()`: synchronously reserves `attachments` for `runId`, or throws (releasing
@@ -384,13 +383,15 @@ export function sanitizeAttachmentName({ requestedName }, _optional = {}) {
384
383
  return 'attachment';
385
384
  return basename(requestedName).replaceAll(/[^a-zA-Z0-9._ -]/gu, '_') || 'attachment';
386
385
  }
386
+ /** An attachment no run has claimed, or one claimed by `runId` (never by an absent run id). */
387
+ function isUnclaimedOrClaimedBy(claimedRunId, runId) {
388
+ return claimedRunId === undefined || (runId !== undefined && claimedRunId === runId);
389
+ }
390
+ /** The complete PNG signature includes the CR/LF and control-byte corruption markers. */
391
+ const PNG_SIGNATURE_PREFIX = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
387
392
  /** `true` when `body`'s first 8 bytes are the PNG signature. */
388
393
  export function hasPngSignature({ body }, _optional = {}) {
389
- return body.length >= 8
390
- && body[0] === 0x89
391
- && body[1] === 0x50
392
- && body[2] === 0x4e
393
- && body[3] === 0x47;
394
+ return body.length >= 8 && PNG_SIGNATURE_PREFIX.every((byte, index) => body[index] === byte);
394
395
  }
395
396
  /** `true` when `body`'s first 3 bytes are the JPEG start-of-image marker. */
396
397
  export function hasJpegSignature({ body }, _optional = {}) {
@@ -934,7 +935,7 @@ export async function createDiskAttachmentStore(requiredArgs, optionalArgs = {})
934
935
  // what makes an ownerless record excluded rather than accidentally matched by a falsy-ish
935
936
  // `ownerId` argument — see this method's own doc on why that must never be a wildcard.
936
937
  .filter((record) => record.ownerId !== undefined && record.ownerId === ownerId
937
- && (record.claimedRunId === undefined || (runId !== undefined && record.claimedRunId === runId)))
938
+ && isUnclaimedOrClaimedBy(record.claimedRunId, runId))
938
939
  .sort((a, b) => a.createdAt - b.createdAt)
939
940
  .map((record) => ({ ref: record.id, name: record.name, kind: record.kind, size: record.size, createdAt: record.createdAt }));
940
941
  },
@@ -12,7 +12,7 @@ function parseSearchInput(input) {
12
12
  const rawLimit = input.query.limit;
13
13
  if (rawLimit === undefined)
14
14
  return ok({ value: { query: rawQuery, limit: DEFAULT_SEARCH_LIMIT } });
15
- if (typeof rawLimit !== 'string' || !/^[0-9]+$/.test(rawLimit)) {
15
+ if (typeof rawLimit !== 'string' || !/^\d+$/.test(rawLimit)) {
16
16
  return err({ error: validationError({ message: 'limit must be a single positive-integer query-string value when provided' }) });
17
17
  }
18
18
  const parsed = Number(rawLimit);
@@ -49,6 +49,8 @@ import { createApiError } from '@jini-ai/protocol';
49
49
  import { ClientFacingError, defineJsonRoute, mountJsonRoute } from '@jini-ai/http-kit';
50
50
  import { validationError } from '@jini-ai/http-kit';
51
51
  import { err, ok } from '@jini-ai/http-kit';
52
+ /** Validation message for a request body that is not a JSON object. */
53
+ const BODY_NOT_OBJECT_MESSAGE = 'body must be a JSON object';
52
54
  function defaultInternalErrorSink(context) {
53
55
  // eslint-disable-next-line no-console
54
56
  console.error(`[@jini-ai/http-kit] internal error (connectors/${context.source}.${context.operation}, correlationId=${context.correlationId})`, context.error);
@@ -86,7 +88,7 @@ function firstQueryValue(raw) {
86
88
  // ---------------------------------------------------------------------------
87
89
  function parseAuthCredentials(input) {
88
90
  if (!isRecord(input.body))
89
- return err({ error: validationError({ message: 'body must be a JSON object' }) });
91
+ return err({ error: validationError({ message: BODY_NOT_OBJECT_MESSAGE }) });
90
92
  const email = nonEmptyString(input.body.email);
91
93
  if (!email)
92
94
  return err({ error: validationError({ message: 'email must be a non-empty string' }, { issues: [{ path: 'email', message: 'required non-empty string' }] }) });
@@ -97,7 +99,7 @@ function parseAuthCredentials(input) {
97
99
  }
98
100
  function parseAuthToken(input) {
99
101
  if (!isRecord(input.body))
100
- return err({ error: validationError({ message: 'body must be a JSON object' }) });
102
+ return err({ error: validationError({ message: BODY_NOT_OBJECT_MESSAGE }) });
101
103
  const token = nonEmptyString(input.body.token);
102
104
  if (!token)
103
105
  return err({ error: validationError({ message: 'token must be a non-empty string' }, { issues: [{ path: 'token', message: 'required non-empty string' }] }) });
@@ -164,7 +166,7 @@ function parseStoragePut(input) {
164
166
  if (!keyResult.ok)
165
167
  return keyResult;
166
168
  if (!isRecord(input.body))
167
- return err({ error: validationError({ message: 'body must be a JSON object' }) });
169
+ return err({ error: validationError({ message: BODY_NOT_OBJECT_MESSAGE }) });
168
170
  const dataBase64 = input.body.dataBase64;
169
171
  if (typeof dataBase64 !== 'string' || !isWellFormedBase64(dataBase64)) {
170
172
  return err({ error: validationError({ message: 'dataBase64 must be a non-empty base64-encoded string' }, { issues: [{ path: 'dataBase64', message: 'required base64 string' }] }) });
@@ -231,7 +233,7 @@ function parseDbInsert(input) {
231
233
  if (!collectionResult.ok)
232
234
  return collectionResult;
233
235
  if (!isRecord(input.body))
234
- return err({ error: validationError({ message: 'body must be a JSON object' }) });
236
+ return err({ error: validationError({ message: BODY_NOT_OBJECT_MESSAGE }) });
235
237
  const id = input.body.id;
236
238
  if (typeof id !== 'string' || id.length === 0) {
237
239
  return err({ error: validationError({ message: 'id must be a non-empty string field' }, { issues: [{ path: 'id', message: 'required non-empty string' }] }) });
@@ -252,7 +254,7 @@ function parseDbUpdate(input) {
252
254
  if (!parsed.ok)
253
255
  return parsed;
254
256
  if (!isRecord(input.body))
255
- return err({ error: validationError({ message: 'body must be a JSON object' }) });
257
+ return err({ error: validationError({ message: BODY_NOT_OBJECT_MESSAGE }) });
256
258
  return ok({ value: { ...parsed.value, patch: input.body } });
257
259
  }
258
260
  function parseDbQueryRequest(input) {
@@ -138,6 +138,11 @@ function toolResultToApiResult(deps, source, result) {
138
138
  sink({ source, correlationId, error: result.status === 'failed' ? result.error : result.status });
139
139
  return err({ error: createApiError({ code: 'INTERNAL_ERROR', message: 'an internal error occurred' }, { requestId: correlationId }) });
140
140
  }
141
+ default: {
142
+ // Unreachable: every tool result status has a case above, and a new one fails typecheck here.
143
+ const unhandled = result.status;
144
+ throw new TypeError(`Unhandled tool result status: ${String(unhandled)}`);
145
+ }
141
146
  }
142
147
  }
143
148
  /** `GET /api/daemon/db` — the database inventory (schema version, tables, row counts, size). */
@@ -144,6 +144,11 @@ function toolExecutionResultToApiResult(deps, { runId, toolId, result }) {
144
144
  return err({ error: reportInternalError(deps, { source: 'delegated-tool-execute', error: result.status, runId, toolId, status: result.status }) });
145
145
  case 'failed':
146
146
  return err({ error: failedResultToApiError(deps, { runId, toolId, result }) });
147
+ default: {
148
+ // Unreachable: every tool result status has a case above, and a new one fails typecheck here.
149
+ const unhandled = result.status;
150
+ throw new TypeError(`Unhandled tool result status: ${String(unhandled)}`);
151
+ }
147
152
  }
148
153
  }
149
154
  /** The `failed` arm of {@link toolExecutionResultToApiResult} — see its doc for the three-way split. */
@@ -59,7 +59,8 @@ function reportInternalError(deps, source, error) {
59
59
  * from a bracketed query string) is rejected rather than coerced.
60
60
  */
61
61
  export function parseCapabilityQuery({ raw }, _optional = {}) {
62
- const values = raw === undefined ? [] : (Array.isArray(raw) ? raw : [raw]);
62
+ const listed = Array.isArray(raw) ? raw : [raw];
63
+ const values = raw === undefined ? [] : listed;
63
64
  const capabilities = [];
64
65
  for (const value of values) {
65
66
  if (typeof value !== 'string' || value.trim().length === 0)
@@ -167,11 +168,12 @@ export function registerFrontendSessionRoutes({ app, deps, adapter }, _optional
167
168
  }
168
169
  catch (error) {
169
170
  reportInternalError(deps, 'stream', error);
170
- if (!res.headersSent) {
171
- res.status(500).json({ error: createApiError({ code: 'INTERNAL_ERROR', message: 'an internal error occurred' }) });
171
+ if (res.headersSent) {
172
+ if (!res.writableEnded)
173
+ res.end();
172
174
  }
173
- else if (!res.writableEnded) {
174
- res.end();
175
+ else {
176
+ res.status(500).json({ error: createApiError({ code: 'INTERNAL_ERROR', message: 'an internal error occurred' }) });
175
177
  }
176
178
  }
177
179
  });
@@ -71,6 +71,12 @@ export function applicableForPlatform({ entry, platform }, _optional = {}) {
71
71
  export function defaultProbeEnv(_required, _optional = {}) {
72
72
  return { access: ({ path, mode }) => fsAccess(path, mode), env: process.env, platform: currentPlatform({}) };
73
73
  }
74
+ /** The login-shell bin directories a GUI-launched daemon's `PATH` tends to lack, per platform. */
75
+ function extraProbeDirs(platform, home) {
76
+ if (platform === 'darwin')
77
+ return ['/usr/local/bin', '/opt/homebrew/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin', `${home}/.local/bin`];
78
+ return platform === 'linux' ? ['/usr/local/bin', '/usr/bin', '/bin', `${home}/.local/bin`] : [];
79
+ }
74
80
  /**
75
81
  * `$PATH` directories to probe, plus the common macOS/Linux locations a login shell would have
76
82
  * that a GUI-launched daemon's thin inherited `PATH` often lacks (no `/usr/local/bin`, no
@@ -81,11 +87,7 @@ export function pathDirs(probeEnv) {
81
87
  const raw = probeEnv.env.PATH ?? '';
82
88
  const sep = probeEnv.platform === 'win32' ? ';' : ':';
83
89
  const home = probeEnv.env.HOME ?? '';
84
- const extras = probeEnv.platform === 'darwin'
85
- ? ['/usr/local/bin', '/opt/homebrew/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin', `${home}/.local/bin`]
86
- : probeEnv.platform === 'linux'
87
- ? ['/usr/local/bin', '/usr/bin', '/bin', `${home}/.local/bin`]
88
- : [];
90
+ const extras = extraProbeDirs(probeEnv.platform, home);
89
91
  return [...raw.split(sep), ...extras].filter((dir) => dir.length > 0);
90
92
  }
91
93
  export async function probeCommandOnPath({ command, probeEnv }, _optional = {}) {
@@ -290,7 +290,9 @@ export function registerMemoryEventStream({ app, deps, adapter }, _optional = {}
290
290
  let seq = 0;
291
291
  const channel = createSseChannel({ res });
292
292
  const emit = (kind, data) => {
293
- channel.enqueue({ event: { opaqueCursor: String(seq++), kind, data } });
293
+ const cursor = seq;
294
+ seq += 1;
295
+ channel.enqueue({ event: { opaqueCursor: String(cursor), kind, data } });
294
296
  };
295
297
  const onChange = (event) => emit('change', event);
296
298
  const onExtraction = (event) => emit('extraction', event);
@@ -218,12 +218,12 @@ function toStreamEvent(seq, event) {
218
218
  }
219
219
  /** Answers a failure that happened before any SSE stream existed: a redacted JSON 500 if nothing was sent yet, otherwise just end the response (matching `mountJsonRoute`'s own SEC-005 catch). */
220
220
  function respondInternalError(res, correlationId) {
221
- if (!res.headersSent) {
222
- sendApiError({ res, status: 500, error: createApiError({ code: 'INTERNAL_ERROR', message: 'an internal error occurred' }, { requestId: correlationId }) });
223
- }
224
- else if (!res.writableEnded) {
225
- res.end();
221
+ if (res.headersSent) {
222
+ if (!res.writableEnded)
223
+ res.end();
224
+ return;
226
225
  }
226
+ sendApiError({ res, status: 500, error: createApiError({ code: 'INTERNAL_ERROR', message: 'an internal error occurred' }, { requestId: correlationId }) });
227
227
  }
228
228
  /**
229
229
  * The actual request handler body, shared by every fixed-path route and the `:provider`
@@ -238,6 +238,11 @@ async function runProxyStream({ req, res, adapter, provider, onInternalError, en
238
238
  // reach them however far execution got — including a throw from `guardSameOrigin`/`parse`,
239
239
  // before either exists yet.
240
240
  let seq = 0;
241
+ const nextSeq = () => {
242
+ const current = seq;
243
+ seq += 1;
244
+ return current;
245
+ };
241
246
  let channel;
242
247
  try {
243
248
  const origin = guardSameOrigin({ req, origin: adapter });
@@ -253,7 +258,7 @@ async function runProxyStream({ req, res, adapter, provider, onInternalError, en
253
258
  const openChannel = createSseChannel({ res }, { isEndEvent: ({ event }) => event.kind === 'end' });
254
259
  channel = openChannel;
255
260
  openChannel.open();
256
- await entry.run(parsed.value, (event) => openChannel.enqueue({ event: toStreamEvent(seq++, event) }));
261
+ await entry.run(parsed.value, (event) => openChannel.enqueue({ event: toStreamEvent(nextSeq(), event) }));
257
262
  // No `channel.end()` call here: every reachable exit path inside every turn-runner emits
258
263
  // exactly one `'end'`-kind event before its promise resolves (the duplicate-end-event fix —
259
264
  // see each provider module's own `turn-end-guard.ts` usage, and each function's own
@@ -283,8 +288,8 @@ async function runProxyStream({ req, res, adapter, provider, onInternalError, en
283
288
  // here (an exception escaping `run` — e.g. a caller-supplied `executeTool` throwing — means
284
289
  // no `'end'` event was ever emitted for this connection), and the synthetic `'end'`-kind
285
290
  // event's own `isEndEvent` match closes the channel via the same path as a real one.
286
- channel.enqueue({ event: toStreamEvent(seq++, { type: 'error', message: 'an internal error occurred', code: correlationId }) });
287
- channel.enqueue({ event: toStreamEvent(seq++, { type: 'end', reason: 'error' }) });
291
+ channel.enqueue({ event: toStreamEvent(nextSeq(), { type: 'error', message: 'an internal error occurred', code: correlationId }) });
292
+ channel.enqueue({ event: toStreamEvent(nextSeq(), { type: 'end', reason: 'error' }) });
288
293
  }
289
294
  }
290
295
  /** Erases one provider's concrete parse/run types into the registry's shared entry shape — the single cast every entry needs. */
@@ -1,6 +1,7 @@
1
1
  /** Tavily default adapter. Request limits, timeout and secret-redaction policy remain here. */
2
2
  import { validateBaseUrl } from '@jini-ai/agent-runtime';
3
3
  import { redactSecrets } from '@jini-ai/core';
4
+ import { trimCharsEnd } from '@jini-ai/core/text';
4
5
  const DEFAULT_TAVILY_BASE_URL = 'https://api.tavily.com';
5
6
  /** Tavily's own documented cap on `max_results` (see OD's `tavily.ts#TAVILY_MAX_RESULTS_LIMIT`). */
6
7
  const TAVILY_MAX_RESULTS_LIMIT = 20;
@@ -20,7 +21,7 @@ function resolveTavilyBase(credentials) {
20
21
  if (baseUrlCheck.error) {
21
22
  throw new Error(baseUrlCheck.error);
22
23
  }
23
- return baseUrl.replace(/\/+$/, '');
24
+ return trimCharsEnd({ text: baseUrl, chars: '/' });
24
25
  }
25
26
  function trimmedOrUndefined(value) {
26
27
  return typeof value === 'string' && value.trim() ? value.trim() : undefined;
@@ -3,6 +3,8 @@ import { createApiError } from '@jini-ai/protocol';
3
3
  import { defineJsonRoute, mountJsonRoute } from '@jini-ai/http-kit';
4
4
  import { validationError } from '@jini-ai/http-kit';
5
5
  import { err, ok } from '@jini-ai/http-kit';
6
+ /** The 404 message for an unknown routine id. */
7
+ const ROUTINE_NOT_FOUND_MESSAGE = 'routine not found';
6
8
  function isRecord(value) {
7
9
  return typeof value === 'object' && value !== null && !Array.isArray(value);
8
10
  }
@@ -192,7 +194,7 @@ export const routineGetRoute = defineJsonRoute({
192
194
  method: 'get', path: '/api/routines/:id', parse: parseRoutineId, handle: async ({ input: id, deps }) => {
193
195
  const routine = await deps.store.get({ id });
194
196
  return routine === null
195
- ? err({ error: createApiError({ code: 'NOT_FOUND', message: 'routine not found' }) })
197
+ ? err({ error: createApiError({ code: 'NOT_FOUND', message: ROUTINE_NOT_FOUND_MESSAGE }) })
196
198
  : ok({ value: { routine: attachNextRunAt(routine, deps.scheduler) } });
197
199
  }
198
200
  });
@@ -205,7 +207,7 @@ export const routineUpdateRoute = defineJsonRoute({
205
207
  }
206
208
  const routine = await deps.store.update({ id, patch });
207
209
  if (routine === null)
208
- return err({ error: createApiError({ code: 'NOT_FOUND', message: 'routine not found' }) });
210
+ return err({ error: createApiError({ code: 'NOT_FOUND', message: ROUTINE_NOT_FOUND_MESSAGE }) });
209
211
  deps.scheduler.rescheduleOne({ routineId: id });
210
212
  return ok({ value: { routine: attachNextRunAt(routine, deps.scheduler) } });
211
213
  }
@@ -214,14 +216,14 @@ export const routineDeleteRoute = defineJsonRoute({
214
216
  method: 'delete', path: '/api/routines/:id', parse: parseRoutineId, handle: async ({ input: id, deps }) => {
215
217
  deps.scheduler.unschedule({ routineId: id });
216
218
  const removed = await deps.store.delete({ id });
217
- return removed ? ok({ value: { ok: true } }) : err({ error: createApiError({ code: 'NOT_FOUND', message: 'routine not found' }) });
219
+ return removed ? ok({ value: { ok: true } }) : err({ error: createApiError({ code: 'NOT_FOUND', message: ROUTINE_NOT_FOUND_MESSAGE }) });
218
220
  }
219
221
  }, { requireSameOrigin: true });
220
222
  export const routineRunNowRoute = defineJsonRoute({
221
223
  method: 'post', path: '/api/routines/:id/run', parse: parseRoutineId, handle: async ({ input: id, deps }) => {
222
224
  const existing = await deps.store.get({ id });
223
225
  if (existing === null)
224
- return err({ error: createApiError({ code: 'NOT_FOUND', message: 'routine not found' }) });
226
+ return err({ error: createApiError({ code: 'NOT_FOUND', message: ROUTINE_NOT_FOUND_MESSAGE }) });
225
227
  const started = await deps.scheduler.runNow({ routineId: id });
226
228
  const [routine, run] = await Promise.all([deps.store.get({ id }), deps.store.getLatestRun({ routineId: id })]);
227
229
  return ok({
@@ -239,7 +241,7 @@ export const routineRunsListRoute = defineJsonRoute({
239
241
  method: 'get', path: '/api/routines/:id/runs', parse: parseRoutineRunsQuery, handle: async ({ input: { id, limit }, deps }) => {
240
242
  const existing = await deps.store.get({ id });
241
243
  if (existing === null)
242
- return err({ error: createApiError({ code: 'NOT_FOUND', message: 'routine not found' }) });
244
+ return err({ error: createApiError({ code: 'NOT_FOUND', message: ROUTINE_NOT_FOUND_MESSAGE }) });
243
245
  const runs = await deps.store.listRuns({ routineId: id, limit });
244
246
  return ok({ value: { runs } });
245
247
  }
@@ -60,6 +60,11 @@ function toolResultToApiResult(deps, result) {
60
60
  sink({ source: 'terminal-create', correlationId, error: result.status === 'failed' ? result.error : result.status });
61
61
  return err({ error: createApiError({ code: 'INTERNAL_ERROR', message: 'an internal error occurred' }, { requestId: correlationId }) });
62
62
  }
63
+ default: {
64
+ // Unreachable: every tool result status has a case above, and a new one fails typecheck here.
65
+ const unhandled = result.status;
66
+ throw new TypeError(`Unhandled tool result status: ${String(unhandled)}`);
67
+ }
63
68
  }
64
69
  }
65
70
  function parseTerminalCreate(input) {
@@ -12,7 +12,7 @@ function parseSearchInput(input) {
12
12
  const rawLimit = input.query.limit;
13
13
  if (rawLimit === undefined)
14
14
  return ok({ value: { query: rawQuery, limit: DEFAULT_SEARCH_LIMIT } });
15
- if (typeof rawLimit !== 'string' || !/^[0-9]+$/.test(rawLimit)) {
15
+ if (typeof rawLimit !== 'string' || !/^\d+$/.test(rawLimit)) {
16
16
  return err({ error: validationError({ message: 'limit must be a single positive-integer query-string value when provided' }) });
17
17
  }
18
18
  const parsed = Number(rawLimit);
package/dist/http/xai.js CHANGED
@@ -123,6 +123,9 @@ import { createApiError } from '@jini-ai/protocol';
123
123
  import { defineJsonRoute, mountJsonRoute } from '@jini-ai/http-kit';
124
124
  import { validationError } from '@jini-ai/http-kit';
125
125
  import { err, ok } from '@jini-ai/http-kit';
126
+ import { trimCharsEnd } from '@jini-ai/core/text';
127
+ /** The redacted message every internal failure answers with; details go to the error sink. */
128
+ const INTERNAL_ERROR_MESSAGE = 'an internal error occurred';
126
129
  const DEFAULT_TOKEN_FILE_NAME = 'xai-oauth-token.json';
127
130
  /** Matches the loopback listener's own 30-minute self-close timeout (OD's real comment: keeps the
128
131
  * PKCE state, the open socket, and any paste-back UI expiring together). */
@@ -361,7 +364,7 @@ export const xaiOauthStartRoute = defineJsonRoute({
361
364
  await stopListener(resolved);
362
365
  const correlationId = randomUUID();
363
366
  resolved.onInternalError({ source: 'oauth-start', correlationId, error });
364
- return err({ error: createApiError({ code: 'INTERNAL_ERROR', message: 'an internal error occurred' }, { requestId: correlationId }) });
367
+ return err({ error: createApiError({ code: 'INTERNAL_ERROR', message: INTERNAL_ERROR_MESSAGE }, { requestId: correlationId }) });
365
368
  }
366
369
  });
367
370
  }
@@ -410,7 +413,7 @@ export const xaiOauthCompleteRoute = defineJsonRoute({
410
413
  }
411
414
  const correlationId = randomUUID();
412
415
  resolved.onInternalError({ source: 'oauth-complete', correlationId, error: redactError(error) });
413
- return err({ error: createApiError({ code: 'INTERNAL_ERROR', message: 'an internal error occurred' }, { requestId: correlationId }) });
416
+ return err({ error: createApiError({ code: 'INTERNAL_ERROR', message: INTERNAL_ERROR_MESSAGE }, { requestId: correlationId }) });
414
417
  }
415
418
  }
416
419
  }, { requireSameOrigin: true });
@@ -448,7 +451,7 @@ export const xaiAuthStatusRoute = defineJsonRoute({
448
451
  catch (error) {
449
452
  const correlationId = randomUUID();
450
453
  resolved.onInternalError({ source: 'auth-status', correlationId, error });
451
- return err({ error: createApiError({ code: 'INTERNAL_ERROR', message: 'an internal error occurred' }, { requestId: correlationId }) });
454
+ return err({ error: createApiError({ code: 'INTERNAL_ERROR', message: INTERNAL_ERROR_MESSAGE }, { requestId: correlationId }) });
452
455
  }
453
456
  }
454
457
  }, { requireSameOrigin: true });
@@ -466,7 +469,7 @@ export const xaiOauthDisconnectRoute = defineJsonRoute({
466
469
  catch (error) {
467
470
  const correlationId = randomUUID();
468
471
  resolved.onInternalError({ source: 'oauth-disconnect', correlationId, error });
469
- return err({ error: createApiError({ code: 'INTERNAL_ERROR', message: 'an internal error occurred' }, { requestId: correlationId }) });
472
+ return err({ error: createApiError({ code: 'INTERNAL_ERROR', message: INTERNAL_ERROR_MESSAGE }, { requestId: correlationId }) });
470
473
  }
471
474
  }
472
475
  }, { requireSameOrigin: true });
@@ -594,7 +597,7 @@ async function callXaiSearch(resolved, accessToken, request) {
594
597
  const baseUrlCheck = validateBaseUrl({ baseUrl: resolved.searchBaseUrl });
595
598
  if (baseUrlCheck.error)
596
599
  throw new Error(baseUrlCheck.error);
597
- const base = resolved.searchBaseUrl.replace(/\/+$/, '');
600
+ const base = trimCharsEnd({ text: resolved.searchBaseUrl, chars: '/' });
598
601
  const model = request.model ?? resolved.searchDefaultModel;
599
602
  const requestBody = {
600
603
  model,
@@ -633,7 +636,7 @@ export const xaiSearchRoute = defineJsonRoute({
633
636
  catch (error) {
634
637
  const correlationId = randomUUID();
635
638
  resolved.onInternalError({ source: 'search', correlationId, error });
636
- return err({ error: createApiError({ code: 'INTERNAL_ERROR', message: 'an internal error occurred' }, { requestId: correlationId }) });
639
+ return err({ error: createApiError({ code: 'INTERNAL_ERROR', message: INTERNAL_ERROR_MESSAGE }, { requestId: correlationId }) });
637
640
  }
638
641
  if (!credential) {
639
642
  // No entitlement gate here — see module doc's "Dropped: OD's SuperGrok subscription gate"
@@ -649,7 +652,7 @@ export const xaiSearchRoute = defineJsonRoute({
649
652
  catch (error) {
650
653
  const correlationId = randomUUID();
651
654
  resolved.onInternalError({ source: 'search', correlationId, error });
652
- return err({ error: createApiError({ code: 'INTERNAL_ERROR', message: 'an internal error occurred' }, { requestId: correlationId }) });
655
+ return err({ error: createApiError({ code: 'INTERNAL_ERROR', message: INTERNAL_ERROR_MESSAGE }, { requestId: correlationId }) });
653
656
  }
654
657
  }
655
658
  }, { requireSameOrigin: true });
@@ -122,18 +122,6 @@ export declare function dataDirHasExistingPayload({ dataDir, config }: {
122
122
  }, { filesystem: fs }?: {
123
123
  filesystem?: LegacyFilesystemPort;
124
124
  }): string[];
125
- /**
126
- * Move staged payload entries into the final `dataDir`. Each entry is moved
127
- * with `renameSync` (atomic on the same filesystem); a cross-device rename
128
- * falls back to copy + remove. Rollback-safe in two layers: the copy
129
- * fallback removes a partial destination on its own failure, and the outer
130
- * loop removes every entry it already promoted before rethrowing.
131
- *
132
- * @param stagingDir - The sibling staging directory holding the copied payload.
133
- * @param dataDir - The destination data root.
134
- * @param entries - The payload entries to promote.
135
- * @returns The entries that were placed into `dataDir` before any failure.
136
- */
137
125
  export declare function promoteStaged({ stagingDir, dataDir, entries }: {
138
126
  readonly stagingDir: string;
139
127
  readonly dataDir: string;
@@ -133,6 +133,26 @@ function stagePayload(legacyDir, stagingDir, payloadEntries, fs) {
133
133
  * @param entries - The payload entries to promote.
134
134
  * @returns The entries that were placed into `dataDir` before any failure.
135
135
  */
136
+ /** Renames `src` to `dst`, falling back to copy-then-delete (e.g. across devices); a failed copy
137
+ * removes its partial `dst` before rethrowing. */
138
+ function moveEntry(src, dst, fs) {
139
+ try {
140
+ fs.renameSync(src, dst);
141
+ }
142
+ catch {
143
+ copyThenRemove(src, dst, fs);
144
+ }
145
+ }
146
+ function copyThenRemove(src, dst, fs) {
147
+ try {
148
+ fs.cpSync(src, dst, { recursive: true, force: true, errorOnExist: false });
149
+ fs.rmSync(src, { recursive: true, force: true });
150
+ }
151
+ catch (cpErr) {
152
+ fs.rmSync(dst, { recursive: true, force: true });
153
+ throw cpErr;
154
+ }
155
+ }
136
156
  export function promoteStaged({ stagingDir, dataDir, entries }, { filesystem: fs = nodeFilesystem } = {}) {
137
157
  fs.mkdirSync(dataDir, { recursive: true });
138
158
  const promoted = [];
@@ -140,19 +160,7 @@ export function promoteStaged({ stagingDir, dataDir, entries }, { filesystem: fs
140
160
  for (const entry of entries) {
141
161
  const src = path.join(stagingDir, entry);
142
162
  const dst = path.join(dataDir, entry);
143
- try {
144
- fs.renameSync(src, dst);
145
- }
146
- catch {
147
- try {
148
- fs.cpSync(src, dst, { recursive: true, force: true, errorOnExist: false });
149
- fs.rmSync(src, { recursive: true, force: true });
150
- }
151
- catch (cpErr) {
152
- fs.rmSync(dst, { recursive: true, force: true });
153
- throw cpErr;
154
- }
155
- }
163
+ moveEntry(src, dst, fs);
156
164
  promoted.push(entry);
157
165
  }
158
166
  }
@@ -146,7 +146,8 @@ function readToolStream({ res, settle, fail }, { emitSurface }) {
146
146
  res.on('data', (chunk) => {
147
147
  buffer += chunk;
148
148
  const lines = buffer.split('\n');
149
- buffer = lines.pop() ?? '';
149
+ // String.split always produces at least one member, including for an empty chunk.
150
+ buffer = lines.pop();
150
151
  lines.forEach(onLine);
151
152
  });
152
153
  // Once the terminal line is in hand the outcome is known, so a later reset cannot unsettle it.
@@ -110,7 +110,8 @@ export function tzWallToUtcGapFallback({ timezone, year, month, day, hour, minut
110
110
  }
111
111
  function matchesWallClock(timezone, at, year, month, day, hour, minute) {
112
112
  const p = partsInTimezone({ timezone: timezone, atUtc: at });
113
- return p.year === year && p.month === month && p.day === day && p.hour === hour && p.minute === minute;
113
+ const sameDate = p.year === year && p.month === month && p.day === day;
114
+ return sameDate && p.hour === hour && p.minute === minute;
114
115
  }
115
116
  /** Minutes east of UTC for `timezone` at instant `at`. e.g. Asia/Shanghai returns 480. */
116
117
  function tzOffsetMinutes(timezone, at) {
@@ -43,6 +43,11 @@ export class ScheduledRunPersistenceError extends Error {
43
43
  this.name = 'ScheduledRunPersistenceError';
44
44
  }
45
45
  }
46
+ /** What a failed scheduled run logs: the underlying error's message when there is one. */
47
+ function scheduledRunFailureDetail(error) {
48
+ const cause = error instanceof ScheduledRunPersistenceError ? error.originalError : error;
49
+ return cause instanceof Error ? cause.message : cause;
50
+ }
46
51
  function isScheduledRunPersistenceError(error) {
47
52
  return error instanceof ScheduledRunPersistenceError;
48
53
  }
@@ -136,13 +141,7 @@ export class RoutineService {
136
141
  this.rescheduleOne({ routineId: routine.id });
137
142
  })
138
143
  .catch((error) => {
139
- console.error(`[@jini-ai/daemon] routine ${routine.id} scheduled run failed:`, error instanceof ScheduledRunPersistenceError
140
- ? error.originalError instanceof Error
141
- ? error.originalError.message
142
- : error.originalError
143
- : error instanceof Error
144
- ? error.message
145
- : error);
144
+ console.error(`[@jini-ai/daemon] routine ${routine.id} scheduled run failed:`, scheduledRunFailureDetail(error));
146
145
  if (isScheduledRunPersistenceError(error)) {
147
146
  this.retryScheduledSlot(routine.id, fireAt);
148
147
  }
@@ -101,7 +101,8 @@ function terminalStateFromEndEntry(entry) {
101
101
  if (!isRecord(entry.data))
102
102
  return { state: 'failed', resumable: false };
103
103
  const status = entry.data.status;
104
- const state = status === 'succeeded' ? 'succeeded' : status === 'canceled' ? 'cancelled' : 'failed';
104
+ const unsuccessful = status === 'canceled' ? 'cancelled' : 'failed';
105
+ const state = status === 'succeeded' ? 'succeeded' : unsuccessful;
105
106
  return { state, resumable: entry.data.resumable === true };
106
107
  }
107
108
  // ---------------------------------------------------------------------------