@sema-agent/client-core 0.88.0 → 0.89.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -25,6 +25,12 @@ export const PLUGIN_HOOK_REASONS = [
25
25
  'shell_form_user_config',
26
26
  'user_config_unset',
27
27
  'malformed_entry',
28
+ 'engine_hooks_off',
29
+ 'engine_hooks_unknown',
30
+ 'engine_type_skipped',
31
+ 'engine_llm_limit',
32
+ 'engine_unconfirmed',
33
+ 'engine_elsewhere',
28
34
  ];
29
35
  export const PLUGIN_HOOK_EXCLUSION_REASONS = [
30
36
  'workspace_untrusted',
@@ -32,15 +38,69 @@ export const PLUGIN_HOOK_EXCLUSION_REASONS = [
32
38
  'policy_disable_all',
33
39
  'startup_mode',
34
40
  'managed_plugins_only',
41
+ 'prompt_not_user_authored',
35
42
  ];
36
43
  export const SETTINGS_HOOK_DROP_REASONS = ['exec_form_unsupported', 'mcp_tool_unsupported'];
37
- export const ENGINE_HOOK_LIMITS = { maxGroupsPerEvent: 16, maxEntriesPerEvent: 32, maxCommandChars: 8192 };
44
+ export const ENGINE_HOOK_LIMITS = { maxGroupsPerEvent: 16, maxEntriesPerEvent: 32, maxCommandChars: 8192, maxLlmEntriesPerEvent: 4 };
45
+ const ENGINE_HOOK_TYPE_RUNS = new Set(['command', 'http', 'prompt', 'agent']);
46
+ const COMPACTION_EVENTS = new Set(['PreCompact', 'PostCompact']);
47
+ const judged = (disposition, reason) => ({ kind: 'judged', disposition, reason });
48
+ function hookExecutorOf(x) {
49
+ if (x.event === 'UserPromptSubmit' && !x.promptAuthoredByUser)
50
+ return { kind: 'excluded', reason: 'prompt_not_user_authored' };
51
+ if (x.source === 'settings') {
52
+ const why = x.powershell
53
+ ? { reason: 'engine_type_skipped', certain: true }
54
+ : x.engine.hooksCapable === false
55
+ ? { reason: 'engine_hooks_off', certain: true }
56
+ : x.engine.hooksCapable === null
57
+ ? { reason: 'engine_hooks_unknown', certain: false }
58
+ : x.llmPosition > ENGINE_HOOK_LIMITS.maxLlmEntriesPerEvent
59
+ ? { reason: 'engine_llm_limit', certain: false }
60
+ : undefined;
61
+ const compaction = COMPACTION_EVENTS.has(x.event);
62
+ if (why === undefined) {
63
+ if (!x.shellRuns || x.engine.ownedByThisShell === true)
64
+ return judged('engine', 'projected');
65
+ return compaction ? judged('engine', 'engine_unconfirmed') : judged('shell', 'engine_elsewhere');
66
+ }
67
+ if (x.shellRuns && (why.certain || !compaction || why.reason === 'engine_llm_limit'))
68
+ return judged('shell', why.reason);
69
+ return why.certain ? judged('not_run', why.reason) : judged('engine', 'engine_unconfirmed');
70
+ }
71
+ const why = x.engine.ownedByThisShell === null
72
+ ? 'engine_locality_unknown'
73
+ : !x.engine.ownedByThisShell
74
+ ? 'engine_not_local'
75
+ : x.engine.pluginHooksCapable === null
76
+ ? 'engine_capability_unknown'
77
+ : !x.engine.pluginHooksCapable
78
+ ? 'engine_capability_absent'
79
+ : x.engine.hooksCapable === false
80
+ ? 'engine_hooks_off'
81
+ : x.engine.hooksCapable === null
82
+ ? 'engine_hooks_unknown'
83
+ : x.pluginBlock === 'not_command'
84
+ ? 'not_command'
85
+ : x.powershell
86
+ ? 'engine_type_skipped'
87
+ : x.pluginBlock;
88
+ if (why === undefined)
89
+ return judged('engine', 'projected');
90
+ return judged(x.shellRuns ? 'shell' : 'not_run', why);
91
+ }
38
92
  const ENGINE_EVENTS = new Set(ENGINE_FIRED_HOOK_EVENTS);
39
93
  const USER_CONFIG_REF_ONE = /\$\{user_config\.[^}]+\}/;
40
94
  const USER_CONFIG_REF_ALL = /\$\{user_config\.([^}]+)\}/g;
41
95
  function isRecord(v) {
42
96
  return typeof v === 'object' && v !== null && !Array.isArray(v);
43
97
  }
98
+ function ownField(o, key) {
99
+ return Object.hasOwn(o, key) ? o[key] : undefined;
100
+ }
101
+ function ownView(v) {
102
+ return isRecord(v) ? Object.assign(Object.create(null), Object.fromEntries(Object.entries(v))) : v;
103
+ }
44
104
  function nonEmptyString(v) {
45
105
  return typeof v === 'string' && v.length > 0;
46
106
  }
@@ -123,7 +183,7 @@ export function decidePluginHooks(input) {
123
183
  const excluded = [];
124
184
  const candidates = new Map();
125
185
  let unreadable = 0;
126
- const fallback = (event, reason) => input.shellHookEvents.has(event) ? { disposition: 'shell', reason } : { disposition: 'not_run', reason };
186
+ const fallback = (event, type, reason) => input.shellRuns(event, typeof type === 'string' ? type : undefined) ? { disposition: 'shell', reason } : { disposition: 'not_run', reason };
127
187
  input.plugins.forEach((p, pluginIndex) => {
128
188
  if (!isRecord(p) || !isRecord(p.hooks)) {
129
189
  unreadable++;
@@ -155,6 +215,7 @@ export function decidePluginHooks(input) {
155
215
  excluded.push({ ...key, reason: 'managed_plugins_only' });
156
216
  return;
157
217
  }
218
+ const entryType = isRecord(entry) && typeof entry.type === 'string' ? entry.type : undefined;
158
219
  const shape = sourceOk ? classifyEntry(entry, src.options) : { kind: 'malformed' };
159
220
  if (shape.kind === 'malformed') {
160
221
  verdicts.push({ ...key, disposition: 'not_run', reason: 'malformed_entry' });
@@ -171,27 +232,28 @@ export function decidePluginHooks(input) {
171
232
  if (!ENGINE_EVENTS.has(event)) {
172
233
  verdicts.push({
173
234
  ...key,
174
- ...(input.shellHookEvents.has(event)
235
+ ...(input.shellRuns(event, entryType)
175
236
  ? { disposition: 'shell', reason: 'shell_event' }
176
237
  : { disposition: 'not_run', reason: 'no_executor' }),
177
238
  });
178
239
  return;
179
240
  }
180
- const blocked = input.engine.ownedByThisShell === null
181
- ? 'engine_locality_unknown'
182
- : !input.engine.ownedByThisShell
183
- ? 'engine_not_local'
184
- : input.engine.pluginHooksCapable === null
185
- ? 'engine_capability_unknown'
186
- : !input.engine.pluginHooksCapable
187
- ? 'engine_capability_absent'
188
- : shape.kind === 'other'
189
- ? 'not_command'
190
- : declaresSensitive(src.options)
191
- ? 'sensitive_options'
192
- : undefined;
193
- if (blocked !== undefined) {
194
- verdicts.push({ ...key, ...fallback(event, blocked) });
241
+ const ex = hookExecutorOf({
242
+ source: 'plugin',
243
+ event,
244
+ powershell: shape.kind !== 'other' && isRecord(entry) && entry.shell === 'powershell',
245
+ llmPosition: 0,
246
+ engine: input.engine,
247
+ shellRuns: input.shellRuns(event, entryType),
248
+ promptAuthoredByUser: input.promptAuthoredByUser,
249
+ ...(shape.kind === 'other' ? { pluginBlock: 'not_command' } : declaresSensitive(src.options) ? { pluginBlock: 'sensitive_options' } : {}),
250
+ });
251
+ if (ex.kind === 'excluded') {
252
+ excluded.push({ ...key, reason: ex.reason });
253
+ return;
254
+ }
255
+ if (ex.disposition !== 'engine') {
256
+ verdicts.push({ ...key, disposition: ex.disposition, reason: ex.reason });
195
257
  return;
196
258
  }
197
259
  const plugin = { name: src.name, root: src.root, dataDir: src.dataDir };
@@ -218,7 +280,7 @@ export function decidePluginHooks(input) {
218
280
  const cmd = c.wireEntry.command;
219
281
  if (typeof cmd === 'string' && cmd.length <= ENGINE_HOOK_LIMITS.maxCommandChars)
220
282
  return true;
221
- Object.assign(c.verdict, fallback(event, 'engine_limit'));
283
+ Object.assign(c.verdict, fallback(event, c.wireEntry.type, 'engine_limit'));
222
284
  return false;
223
285
  });
224
286
  if (fits.length === 0)
@@ -233,7 +295,7 @@ export function decidePluginHooks(input) {
233
295
  if (occ.groups + groupKeys.length > ENGINE_HOOK_LIMITS.maxGroupsPerEvent ||
234
296
  occ.entries + fits.length > ENGINE_HOOK_LIMITS.maxEntriesPerEvent) {
235
297
  for (const c of fits)
236
- Object.assign(c.verdict, fallback(event, 'engine_limit'));
298
+ Object.assign(c.verdict, fallback(event, c.wireEntry.type, 'engine_limit'));
237
299
  continue;
238
300
  }
239
301
  const groupsOut = [];
@@ -259,12 +321,13 @@ function carriesArgs(entry) {
259
321
  }
260
322
  return true;
261
323
  }
262
- export function filterSettingsHookGroups(source, event, groups, execFormCapable, hostFires, failLoudMcpTool) {
324
+ export function filterSettingsHookGroups(source, event, groups, execFormCapable, shellRunsType, failLoudMcpTool, judge) {
263
325
  const drops = [];
326
+ const verdicts = [];
327
+ const excluded = [];
264
328
  const out = [];
265
329
  let stripped = 0;
266
330
  const engineEvent = ENGINE_EVENTS.has(event);
267
- const notRun = engineEvent && !hostFires;
268
331
  const keepMcpTool = failLoudMcpTool && engineEvent;
269
332
  groups.forEach((group, groupIndex) => {
270
333
  if (!isRecord(group) || !Array.isArray(group.hooks)) {
@@ -284,10 +347,23 @@ export function filterSettingsHookGroups(source, event, groups, execFormCapable,
284
347
  ? 'exec_form_unsupported'
285
348
  : undefined;
286
349
  if (reason !== undefined) {
287
- drops.push({ source, event, groupIndex, hookIndex, reason, notRun });
350
+ drops.push({ source, event, groupIndex, hookIndex, reason, notRun: engineEvent && !shellRunsType(isRecord(entry) && typeof entry.type === 'string' ? entry.type : undefined) });
288
351
  changed = true;
289
352
  return;
290
353
  }
354
+ if (judge !== undefined && engineEvent && isRecord(entry) && judgeableSettingsEntry(entry)) {
355
+ const ex = judge(event, group, entry);
356
+ if (ex.kind === 'excluded') {
357
+ excluded.push({ source, event, groupIndex, hookIndex, reason: ex.reason });
358
+ changed = true;
359
+ return;
360
+ }
361
+ verdicts.push({ source, event, groupIndex, hookIndex, disposition: ex.disposition, reason: ex.reason });
362
+ if (ex.disposition === 'shell') {
363
+ changed = true;
364
+ return;
365
+ }
366
+ }
291
367
  if (isRecord(entry) && Object.hasOwn(entry, 'plugin')) {
292
368
  const copy = { ...entry };
293
369
  delete copy.plugin;
@@ -303,7 +379,51 @@ export function filterSettingsHookGroups(source, event, groups, execFormCapable,
303
379
  else if (kept.length > 0)
304
380
  out.push({ ...group, hooks: kept });
305
381
  });
306
- return { groups: out, drops, stripped };
382
+ return { groups: out, drops, stripped, verdicts, excluded };
383
+ }
384
+ function judgeableSettingsEntry(entry) {
385
+ const type = entry.type;
386
+ if (typeof type !== 'string' || !ENGINE_HOOK_TYPE_RUNS.has(type))
387
+ return false;
388
+ const field = type === 'command' ? entry.command : type === 'http' ? entry.url : entry.prompt;
389
+ return nonEmptyString(field) && !field.includes('\u0000');
390
+ }
391
+ export function createSettingsHookJudge(engine, shellRuns, promptAuthoredByUser) {
392
+ const llm = new Map();
393
+ const seen = new Map();
394
+ return (event, group, entry) => {
395
+ let dk;
396
+ const entryKey = settingsHookEntryKey(entry);
397
+ if (entryKey !== undefined && isRecord(group)) {
398
+ try {
399
+ const { hooks: _hooks, ...rest } = group;
400
+ void _hooks;
401
+ dk = `${event}\u0000${JSON.stringify(rest)}\u0000${entryKey}`;
402
+ }
403
+ catch {
404
+ dk = undefined;
405
+ }
406
+ }
407
+ const prior = dk === undefined ? undefined : seen.get(dk);
408
+ if (prior !== undefined)
409
+ return prior;
410
+ const type = entry.type;
411
+ const isLlm = type === 'prompt' || type === 'agent';
412
+ const ex = hookExecutorOf({
413
+ source: 'settings',
414
+ event,
415
+ powershell: type === 'command' && entry.shell === 'powershell',
416
+ llmPosition: isLlm ? (llm.get(event) ?? 0) + 1 : 0,
417
+ engine,
418
+ shellRuns: shellRuns(event, typeof type === 'string' ? type : undefined),
419
+ promptAuthoredByUser,
420
+ });
421
+ if (isLlm && ex.kind === 'judged' && ex.disposition !== 'shell')
422
+ llm.set(event, (llm.get(event) ?? 0) + 1);
423
+ if (dk !== undefined)
424
+ seen.set(dk, ex);
425
+ return ex;
426
+ };
307
427
  }
308
428
  function settingsHookEntryKey(entry) {
309
429
  if (!isRecord(entry) || typeof entry.type !== 'string')
@@ -427,6 +547,116 @@ export function dedupeSettingsHookGroups(groups) {
427
547
  export function pluginHookVerdictOf(plan, pluginId, event, groupIndex, hookIndex) {
428
548
  return plan.verdicts.find((v) => v.pluginId === pluginId && v.event === event && v.groupIndex === groupIndex && v.hookIndex === hookIndex);
429
549
  }
550
+ function skipMatcherKey(matcher) {
551
+ return matcher === undefined || matcher === null || matcher === '' || matcher === '*' ? 'null' : JSON.stringify(matcher);
552
+ }
553
+ export function settingsHooksKeptLocally(plan, event, group, opts) {
554
+ let raw;
555
+ try {
556
+ raw = group?.hooks;
557
+ }
558
+ catch {
559
+ raw = undefined;
560
+ }
561
+ const hooks = raw;
562
+ if (!Array.isArray(hooks) || hooks.length === 0 || !ENGINE_EVENTS.has(event))
563
+ return hooks;
564
+ try {
565
+ if (COMPACTION_EVENTS.has(event) && opts?.engineCompaction === 'not_performed')
566
+ return hooks;
567
+ const planRec = plan;
568
+ if (!isRecord(planRec))
569
+ return hooks;
570
+ const excludedList = Object.hasOwn(planRec, 'settingsExcluded') ? planRec.settingsExcluded : [];
571
+ const wire = ownField(planRec, 'wire');
572
+ if (!Array.isArray(excludedList) || (wire !== undefined && !isRecord(wire)))
573
+ return hooks;
574
+ if (excludedList.some((e) => settingsExclusionRowOf(e)?.event === event))
575
+ return [];
576
+ const sent = new Set();
577
+ const groups = isRecord(wire) ? ownField(wire, event) : undefined;
578
+ if (Array.isArray(groups)) {
579
+ for (const g of groups) {
580
+ if (!isRecord(g))
581
+ continue;
582
+ const gh = ownField(g, 'hooks');
583
+ if (!Array.isArray(gh))
584
+ continue;
585
+ const mk = skipMatcherKey(ownField(g, 'matcher'));
586
+ for (const h of gh) {
587
+ if (isRecord(h) && Object.hasOwn(h, 'plugin'))
588
+ continue;
589
+ const k = settingsHookEntryKey(ownView(h));
590
+ if (k !== undefined)
591
+ sent.add(`${mk}\u0000${k}`);
592
+ }
593
+ }
594
+ }
595
+ if (sent.size === 0)
596
+ return hooks;
597
+ const mk = skipMatcherKey(isRecord(group) ? group.matcher : undefined);
598
+ let kept = null;
599
+ hooks.forEach((h, i) => {
600
+ const k = settingsHookEntryKey(h);
601
+ const skip = k !== undefined && sent.has(`${mk}\u0000${k}`);
602
+ if (skip)
603
+ kept ??= hooks.slice(0, i);
604
+ else if (kept !== null)
605
+ kept.push(h);
606
+ });
607
+ return kept ?? hooks;
608
+ }
609
+ catch {
610
+ return hooks;
611
+ }
612
+ }
613
+ function settingsRowBaseOf(v) {
614
+ if (!isRecord(v))
615
+ return undefined;
616
+ const source = ownField(v, 'source');
617
+ const event = ownField(v, 'event');
618
+ const groupIndex = ownField(v, 'groupIndex');
619
+ const hookIndex = ownField(v, 'hookIndex');
620
+ if (typeof source !== 'string' || !Object.hasOwn(SOURCE_TEXT, source))
621
+ return undefined;
622
+ if (typeof event !== 'string' || !ENGINE_EVENTS.has(event))
623
+ return undefined;
624
+ if (!Number.isInteger(groupIndex) || groupIndex < 0 || !Number.isInteger(hookIndex) || hookIndex < 0)
625
+ return undefined;
626
+ return { source: source, event, groupIndex: groupIndex, hookIndex: hookIndex, o: v };
627
+ }
628
+ const DISPOSITION_SET = new Set(PLUGIN_HOOK_DISPOSITIONS);
629
+ const REASON_SET = new Set(PLUGIN_HOOK_REASONS);
630
+ const EXCLUSION_REASON_SET = new Set(PLUGIN_HOOK_EXCLUSION_REASONS);
631
+ function settingsVerdictRowOf(v) {
632
+ try {
633
+ const b = settingsRowBaseOf(v);
634
+ if (b === undefined)
635
+ return undefined;
636
+ const disposition = ownField(b.o, 'disposition');
637
+ const reason = ownField(b.o, 'reason');
638
+ if (typeof disposition !== 'string' || !DISPOSITION_SET.has(disposition) || typeof reason !== 'string' || !REASON_SET.has(reason))
639
+ return undefined;
640
+ return { source: b.source, event: b.event, groupIndex: b.groupIndex, hookIndex: b.hookIndex, disposition: disposition, reason: reason };
641
+ }
642
+ catch {
643
+ return undefined;
644
+ }
645
+ }
646
+ function settingsExclusionRowOf(v) {
647
+ try {
648
+ const b = settingsRowBaseOf(v);
649
+ if (b === undefined)
650
+ return undefined;
651
+ const reason = ownField(b.o, 'reason');
652
+ if (typeof reason !== 'string' || !EXCLUSION_REASON_SET.has(reason))
653
+ return undefined;
654
+ return { source: b.source, event: b.event, groupIndex: b.groupIndex, hookIndex: b.hookIndex, reason: reason };
655
+ }
656
+ catch {
657
+ return undefined;
658
+ }
659
+ }
430
660
  const REASON_TEXT = {
431
661
  projected: 'it is sent to the engine',
432
662
  shell_event: 'this client runs this event itself',
@@ -441,6 +671,12 @@ const REASON_TEXT = {
441
671
  shell_form_user_config: 'its command uses ${user_config.…} in shell form, which is refused (use exec form with "args", or read $CLAUDE_PLUGIN_OPTION_<KEY>)',
442
672
  user_config_unset: 'its command uses a plugin option that has not been set',
443
673
  malformed_entry: 'the hook entry is malformed',
674
+ engine_hooks_off: 'the connected engine ignores hooks sent with requests',
675
+ engine_hooks_unknown: 'it is not known whether the connected engine runs hooks sent with requests',
676
+ engine_type_skipped: 'the engine does not run PowerShell command hooks',
677
+ engine_llm_limit: 'the engine may skip prompt or agent hooks after the first four on one event',
678
+ engine_unconfirmed: 'it is sent to the engine, which is expected to run it, but this client cannot confirm that',
679
+ engine_elsewhere: 'it is not confirmed that the engine was started by this client on this machine, so this client runs it itself',
444
680
  };
445
681
  const DROP_TEXT = {
446
682
  exec_form_unsupported: 'exec-form hooks (with "args") are sent to the engine only once it reports support for them',
@@ -545,5 +781,22 @@ export function hooksNotRunNotice(plan) {
545
781
  const events = row.events.map((e) => noticeLabel(e)).join(', ');
546
782
  lines.push(`${SOURCE_TEXT[row.source]}: ${events} ${row.count === 1 ? 'hook' : 'hooks'} will not run — ${settingsHookDropText(row.reason)}.`);
547
783
  }
784
+ const settingsVerdicts = plan.settingsVerdicts;
785
+ const byVerdict = new Map();
786
+ for (const raw of Array.isArray(settingsVerdicts) ? settingsVerdicts : []) {
787
+ const v = settingsVerdictRowOf(raw);
788
+ if (v === undefined || v.disposition !== 'not_run')
789
+ continue;
790
+ const k = `${v.source}\u0000${v.reason}`;
791
+ const row = byVerdict.get(k) ?? { source: v.source, reason: v.reason, events: [], count: 0 };
792
+ if (!row.events.includes(v.event))
793
+ row.events.push(v.event);
794
+ row.count++;
795
+ byVerdict.set(k, row);
796
+ }
797
+ for (const row of byVerdict.values()) {
798
+ const events = row.events.map((e) => noticeLabel(e)).join(', ');
799
+ lines.push(`${SOURCE_TEXT[row.source]}: ${events} ${row.count === 1 ? 'hook' : 'hooks'} will not run — ${pluginHookReasonText(row.reason)}.`);
800
+ }
548
801
  return lines;
549
802
  }
@@ -0,0 +1,6 @@
1
+ import { type RunTerminalFailureDetail } from './runTerminal.js';
2
+ export interface ProviderRejectionResend {
3
+ resend: 'edit_and_resend' | 'not_by_editing';
4
+ text: string;
5
+ }
6
+ export declare function providerRejectionResend(detail: RunTerminalFailureDetail | null | undefined): ProviderRejectionResend | undefined;
@@ -0,0 +1,52 @@
1
+ import { readRunTerminal } from './runTerminal.js';
2
+ const PROVIDER_STATUS_TIERS = Object.freeze([
3
+ Object.freeze([400, 400, 'invalid_request']),
4
+ Object.freeze([401, 401, 'authentication_failed']),
5
+ Object.freeze([403, 403, 'authentication_failed']),
6
+ Object.freeze([404, 404, 'model_not_found']),
7
+ Object.freeze([413, 413, 'invalid_request']),
8
+ Object.freeze([422, 422, 'invalid_request']),
9
+ Object.freeze([429, 429, 'rate_limit']),
10
+ Object.freeze([500, 599, 'server_error']),
11
+ ]);
12
+ const UNTIERED = 'unknown';
13
+ function providerStatusTier(status) {
14
+ for (const [lo, hi, tier] of PROVIDER_STATUS_TIERS)
15
+ if (status >= lo && status <= hi)
16
+ return tier;
17
+ return UNTIERED;
18
+ }
19
+ const EDITABLE_TIER = 'invalid_request';
20
+ const NOT_BY_EDITING_TAIL = new Map([
21
+ ['authentication_failed', ': check the provider credentials or access for this account.'],
22
+ ['model_not_found', ': the model is not available from this provider — choose another model.'],
23
+ ['rate_limit', ': the provider is rate-limiting this account — wait and try again.'],
24
+ ['server_error', ': the provider reported an internal error — try again later.'],
25
+ [UNTIERED, ' for a reason that editing it will not fix — check the provider account or try again later.'],
26
+ ]);
27
+ const PROVIDER_REJECTED = 'provider_rejected';
28
+ export function providerRejectionResend(detail) {
29
+ let read;
30
+ try {
31
+ const r = readRunTerminal({ terminal: { kind: 'failed', detail } });
32
+ read = r !== null && r.kind === 'failed' && Object.hasOwn(r, 'detail') ? r.detail : undefined;
33
+ }
34
+ catch {
35
+ return undefined;
36
+ }
37
+ if (read === undefined || !Object.hasOwn(read, 'kind') || read.kind !== PROVIDER_REJECTED)
38
+ return undefined;
39
+ const status = Object.hasOwn(read, 'status') ? read.status : undefined;
40
+ if (status === undefined)
41
+ return undefined;
42
+ const tier = providerStatusTier(status);
43
+ return tier === EDITABLE_TIER
44
+ ? {
45
+ resend: 'edit_and_resend',
46
+ text: `The model provider rejected this request as sent (HTTP ${status}). Change it — for example shorten it or remove an attachment — and send it again.`,
47
+ }
48
+ : {
49
+ resend: 'not_by_editing',
50
+ text: `The model provider refused this request (HTTP ${status})${NOT_BY_EDITING_TAIL.get(tier) ?? NOT_BY_EDITING_TAIL.get(UNTIERED) ?? ''}`,
51
+ };
52
+ }
@@ -51,6 +51,7 @@ export interface TaskRequestInput {
51
51
  fileHistory?: 'disabled';
52
52
  inheritEnv?: InheritEnvWord;
53
53
  requireExistingSession?: TaskRequest['requireExistingSession'];
54
+ disableSkills?: true;
54
55
  rewind?: Record<string, unknown>;
55
56
  repo?: string;
56
57
  council?: boolean;
@@ -74,6 +75,7 @@ export interface TaskRequestOmission {
74
75
  export declare function taskRequestOmissionCauseDetail(cause: unknown): string;
75
76
  export declare const TASK_REQUEST_OMISSION_CAUSE_NOTICES: Readonly<Record<TaskRequestOmissionCause, string>>;
76
77
  export declare function taskRequestOmissionCauseNotice(cause: unknown): string;
78
+ export declare function taskRequestOmissionCausePhrase(cause: unknown): string;
77
79
  export interface TaskRequestAssembly {
78
80
  readonly request: TaskRequestLike;
79
81
  readonly omitted: readonly TaskRequestOmission[];
@@ -1,3 +1,4 @@
1
+ import { refuseBadDisableSkills, refuseDisableSkillsConflict } from '../disableSkillsWire.js';
1
2
  export const REQUEST_FIELD_MATRIX = [
2
3
  { field: 'objective', lanes: ['interactive', 'print', 'utility'], live: false, why: '本 turn 的真实输入,每条车道都必需', whyUtilityLane: '恒座:三条车道都必须带上本次提交的模型面输入' },
3
4
  { field: 'sessionId', lanes: ['interactive', 'print', 'utility'], live: false, why: '单命名空间(TP-A Step 2):壳 id 即引擎 session id;print 在非 live 下保留 mock 常量', whyUtilityLane: '恒座:side-channel 提交走**独立**会话 id(壳另铸一个,绝不复用用户会话那一个 —— 复用会把这次提交的转录混进用户会话)' },
@@ -25,6 +26,7 @@ export const REQUEST_FIELD_MATRIX = [
25
26
  { field: 'fileHistory', lanes: ['interactive', 'print'], live: true, why: '「这一次运行别捕获文件历史」的**请求级声明**(server 7.104.0 提交体键,单成员闭集 "disabled";`POST /v1/tasks` · `/v1/tasks/stream` · `/v1/runs` 同收,子代继承)。两条用户车道都是提交面、都跑在用户的文件上 ⇒ 都能声明;「本会话不捕获」= 每个提交都带(续跑腿重放同一个体)。🔴 只在能力读数 `on-optional` 时发(老 server 对表外键 400),值由片段口 `fileHistoryDeclarationField` 唯一铸出。与 rewind 还原键同交 ⇒ 上游 400 `request.field_conflict`', whyUtilityLane: '无座:side-channel 强制零工具面(`excludeAllTools`),不会改任何文件 ⇒ 没有可捕获的东西;带上它只会在不认识这一键的老 server 上给这条提交多一种 400' },
26
27
  { field: 'inheritEnv', lanes: ['interactive', 'print'], live: true, why: '模型驱动的宿主 shell「继承多少用户环境」的**请求级声明**(server 7.106.0 提交体键,两词闭集 "all" / "scrub";`POST /v1/tasks` · `/v1/tasks/stream` · `/v1/runs` 同收,续跑腿重放同判)。两条用户车道都是提交面、都可能在用户机器上跑 shell ⇒ 都能声明。本层两词原样透传、不按车道 / 部署预判(非 host 车道 × "all" 400、托管 × "all" 422 归上游判)。🔴 缺席 = 缺省清洗且清掉本会话上一条声明 ⇒ 要「本会话整份继承」就每个提交都带。🔴 只在服务端版本 ≥ 7.106.0 时发(老 server 对表外键整条 400),值由片段口 `inheritEnvDeclaration` 唯一铸出', whyUtilityLane: '无座:side-channel 强制零工具面(`excludeAllTools`),没有模型驱动的 shell 去继承环境;带上它只会在不认这一键的老服务端上给这条提交多一种 400' },
27
28
  { field: 'requireExistingSession', lanes: ['interactive', 'print'], live: true, why: '「只续已有会话」的**请求级断言**(sdk `TaskRequest.requireExistingSession`):`true` 且会话不在 ⇒ 服务端在任何东西跑起来之前拒(会话不在 / 没给会话 id 两形),而**不是**静默新开一个同 id 的空会话;缺席 / `false` = 历史的「找不到就新开」。两条用户车道都有「续某个会话」的入口 ⇒ 都能声明。本层只认字面 `true`(`false` 与缺席同义、不发);非布尔坏值响亮拒(上游对它 400);`true` 而入参没有可续的会话 id ⇒ 装配期按名响亮拒,不留给上游。上游两形的拒绝经 `requireExistingSessionRefusalFromError` 归成一句', whyUtilityLane: '无座:side-channel 每一次提交都走壳另铸的**独立**会话 id(见 sessionId 那一行),从不续任何已有会话 ⇒「必须已存在」对它没有意义,带上去只会让每一次提交都被拒' },
29
+ { field: 'disableSkills', lanes: ['interactive', 'print'], live: true, why: '「这一发一只技能都不装」的**请求级声明**(出厂技能、调用方给的技能、子代定义里的技能一个都不进引擎;其余工具照挂 —— 与 excludeAllTools 是两件事,两键同带各自生效)。两条用户车道都是提交面 ⇒ 都能声明。本层只认字面 true(false 与缺席同义、不发);非布尔坏值响亮拒(上游对它 400);与非空 skills 或任一非空 agents[].skills 同在 ⇒ 装配期按名响亮拒(上游对这一组合 400),兜底层落 agents 之后同判。🔴 只在能力位读数为真时发(老服务端对表外键整条 400),值由片段口 disableSkillsDeclaration 唯一铸出', whyUtilityLane: '无座:同 skills 一行 —— side-channel 是零工具面,装不装技能都无从谈起' },
28
30
  { field: 'finalVerification', lanes: ['print'], live: true, why: 'P1-1 终验:无人值守车道才需要引擎自证;交互 REPL 由人当场看结果。#106 裁 B(让位+告知)后交互面默认关', whyUtilityLane: '无座:终验让引擎再跑一轮自证,而 side-channel 要的是一次小提交的即时答案;「无人值守才需要引擎自证」这条理由在本车道上让位给「小预算优先」' },
29
31
  { field: 'limits', lanes: ['print', 'utility'], live: true, why: 'P2-3-b:`-p` 的预算护栏(--max-* flag 族),交互 REPL 由人随时 Esc', whyUtilityLane: '有座:side-channel 必须带预算护栏(墙钟 / 输出 token / 轮数)—— 它同样没有人看着,而且没有后续回合可以中断' },
30
32
  { field: 'interactiveTools', lanes: ['print', 'utility'], live: true, why: '[909]B 件3:无人值守 stamp false,从 roster 源头灭掉 AskUserQuestion/plan 门。交互车道 stamp false 等于自废武功', whyUtilityLane: '有座:side-channel 绝不该弹交互问答 / plan 门(没有人在看这条提交)。它与 excludeAllTools 是**两条独立**的收紧声明、在这条车道上**并存**(本车道强制带卸载声明,见 `refuseUtilityWithoutToolUnload`;这一位是它旁边那条「连问都别问」的声明,不是它的降级替身)' },
@@ -76,18 +78,40 @@ export function taskRequestOmissionCauseDetail(cause) {
76
78
  const word = typeof cause === 'string' && cause.length > 0 ? cause : '(none)';
77
79
  return `a cause this build does not know (${word}) — this build does not know this cause: receipt causes come from this build's own frozen vocabulary, so a word outside it was not produced here; check whatever supplied it`;
78
80
  }
79
- export const TASK_REQUEST_OMISSION_CAUSE_NOTICES = Object.freeze({
80
- upstream_absent: 'Not sent with this request: this version has no way to send this setting in any request, so giving it or leaving it out makes no difference to what is sent.',
81
- other_channel: 'Not sent from here: that part of the request belongs to a separate step, so what goes out for it, if anything, is decided there rather than by the value given here.',
82
- off_lane: 'Not sent with this request: this kind of request does not carry this setting, so giving it or leaving it out makes no difference to what this request sends.',
83
- not_live: 'Not sent with this request: the request was marked as not going to a live engine (for example an offline or test run), and this setting is only sent on requests that go to one.',
81
+ const TASK_REQUEST_OMISSION_CAUSE_NOTICE_PARTS = Object.freeze({
82
+ upstream_absent: {
83
+ lead: 'Not sent with this request',
84
+ reason: 'this version has no way to send this setting in any request, so giving it or leaving it out makes no difference to what is sent',
85
+ },
86
+ other_channel: {
87
+ lead: 'Not sent from here',
88
+ reason: 'that part of the request belongs to a separate step, so what goes out for it, if anything, is decided there rather than by the value given here',
89
+ },
90
+ off_lane: {
91
+ lead: 'Not sent with this request',
92
+ reason: 'this kind of request does not carry this setting, so giving it or leaving it out makes no difference to what this request sends',
93
+ },
94
+ not_live: {
95
+ lead: 'Not sent with this request',
96
+ reason: 'the request was marked as not going to a live engine (for example an offline or test run), and this setting is only sent on requests that go to one',
97
+ },
84
98
  });
85
- const TASK_REQUEST_OMISSION_UNKNOWN_CAUSE_NOTICE = 'Not sent with this request, for a reason this version does not recognize.';
99
+ const TASK_REQUEST_OMISSION_UNKNOWN_CAUSE_LEAD = 'Not sent with this request';
100
+ const TASK_REQUEST_OMISSION_UNKNOWN_CAUSE_REASON = 'for a reason this version does not recognize';
101
+ const wholeOmissionNotice = (p) => `${p.lead}: ${p.reason}.`;
102
+ export const TASK_REQUEST_OMISSION_CAUSE_NOTICES = Object.freeze(Object.fromEntries(TASK_REQUEST_OMISSION_CAUSES.map((cause) => [cause, wholeOmissionNotice(TASK_REQUEST_OMISSION_CAUSE_NOTICE_PARTS[cause])])));
103
+ const TASK_REQUEST_OMISSION_UNKNOWN_CAUSE_NOTICE = `${TASK_REQUEST_OMISSION_UNKNOWN_CAUSE_LEAD}, ${TASK_REQUEST_OMISSION_UNKNOWN_CAUSE_REASON}.`;
104
+ const omissionNoticePartsOf = (cause) => typeof cause === 'string' && Object.hasOwn(TASK_REQUEST_OMISSION_CAUSE_NOTICE_PARTS, cause)
105
+ ? TASK_REQUEST_OMISSION_CAUSE_NOTICE_PARTS[cause]
106
+ : undefined;
86
107
  export function taskRequestOmissionCauseNotice(cause) {
87
- return typeof cause === 'string' && Object.hasOwn(TASK_REQUEST_OMISSION_CAUSE_NOTICES, cause)
108
+ return omissionNoticePartsOf(cause) !== undefined
88
109
  ? TASK_REQUEST_OMISSION_CAUSE_NOTICES[cause]
89
110
  : TASK_REQUEST_OMISSION_UNKNOWN_CAUSE_NOTICE;
90
111
  }
112
+ export function taskRequestOmissionCausePhrase(cause) {
113
+ return omissionNoticePartsOf(cause)?.reason ?? TASK_REQUEST_OMISSION_UNKNOWN_CAUSE_REASON;
114
+ }
91
115
  const row = (field) => ({ via: 'matrix', field });
92
116
  const INPUT_KEY_ROUTES = {
93
117
  objective: { via: 'always' },
@@ -141,6 +165,7 @@ const INPUT_KEY_ROUTES = {
141
165
  fileHistory: row('fileHistory'),
142
166
  inheritEnv: row('inheritEnv'),
143
167
  requireExistingSession: row('requireExistingSession'),
168
+ disableSkills: row('disableSkills'),
144
169
  rewind: row('resumeAt/resumeAtMode/restoreFiles/rewindFilesTo'),
145
170
  repo: row('repo'),
146
171
  council: row('council'),
@@ -214,6 +239,7 @@ const VALUE_LEVEL_IN_PLAY = Object.freeze({
214
239
  memory: (v) => v === 'off',
215
240
  fileHistory: (v) => v === FILE_HISTORY_DISABLED,
216
241
  requireExistingSession: (v) => v === true,
242
+ disableSkills: (v) => v === true,
217
243
  rewind: (v) => typeof v === 'object' && v !== null && rewindSubKeys().some((k) => hasOwn(v, k) && present(v[k])),
218
244
  });
219
245
  const inPlay = (key, v) => present(v) && (VALUE_LEVEL_IN_PLAY[key]?.(v) ?? true);
@@ -542,7 +568,7 @@ export function parseMaxCostUsd(text) {
542
568
  return { ok: false, why: 'not_positive' };
543
569
  return { ok: true, value };
544
570
  }
545
- const ACCESSOR_READ_ORDER = ['live', 'memoryCapture', 'memory', 'settings', 'maxCostUsd', 'excludeAllTools', 'maxTokens', 'outputRetries', 'fileHistory', 'inheritEnv', 'requireExistingSession'];
571
+ const ACCESSOR_READ_ORDER = ['live', 'memoryCapture', 'memory', 'settings', 'maxCostUsd', 'excludeAllTools', 'maxTokens', 'outputRetries', 'fileHistory', 'inheritEnv', 'requireExistingSession', 'disableSkills'];
546
572
  function dataFirstSnapshot(input, afterData) {
547
573
  let descs;
548
574
  try {
@@ -612,6 +638,7 @@ export function assembleTaskRequest(input, lane) {
612
638
  const fileHistoryIn = src.fileHistory;
613
639
  const inheritEnvIn = src.inheritEnv;
614
640
  const requireExistingSessionIn = src.requireExistingSession;
641
+ const disableSkillsIn = src.disableSkills;
615
642
  const INPUT_KEYS = Object.keys(INPUT_KEY_ROUTES);
616
643
  const i = snapshotByName(src, INPUT_KEYS, {
617
644
  live,
@@ -624,6 +651,7 @@ export function assembleTaskRequest(input, lane) {
624
651
  fileHistory: fileHistoryIn,
625
652
  inheritEnv: inheritEnvIn,
626
653
  requireExistingSession: requireExistingSessionIn,
654
+ disableSkills: disableSkillsIn,
627
655
  settings: settingsIn,
628
656
  });
629
657
  const SETTINGS_KEYS = Object.keys(SETTINGS_KEY_ROUTES);
@@ -637,6 +665,7 @@ export function assembleTaskRequest(input, lane) {
637
665
  refuseBadFileHistory(fileHistoryIn);
638
666
  refuseBadInheritEnv(inheritEnvIn);
639
667
  refuseBadRequireExistingSession(requireExistingSessionIn);
668
+ refuseBadDisableSkills(disableSkillsIn, 'buildTaskRequest');
640
669
  const rewindRoute = INPUT_KEY_ROUTES.rewind;
641
670
  const RETIRED_REWIND_FILES_KEY = 'rewindFiles';
642
671
  const REWIND_KEYS = rewindRoute.via === 'matrix' ? rewindRoute.field.split('/') : [];
@@ -721,6 +750,7 @@ export function assembleTaskRequest(input, lane) {
721
750
  '错误文本只报键名,不回显值。');
722
751
  }
723
752
  refuseRequireExistingWithoutSession(requireExistingSessionIn, i.sessionId);
753
+ refuseDisableSkillsConflict(disableSkillsIn, i.skills, Reflect.get(src, 'agents'), 'buildTaskRequest');
724
754
  refuseUtilityWithoutToolUnload(lane, excludeAllToolsIn);
725
755
  const carries = (route, value, key) => {
726
756
  if (route.via === 'always')
@@ -796,6 +826,7 @@ export function assembleTaskRequest(input, lane) {
796
826
  ...(on('fileHistory') ? { fileHistory: FILE_HISTORY_DISABLED } : {}),
797
827
  ...(on('inheritEnv') ? { inheritEnv: inheritEnvIn } : {}),
798
828
  ...(on('requireExistingSession') ? { requireExistingSession: true } : {}),
829
+ ...(on('disableSkills') ? { disableSkills: true } : {}),
799
830
  ...(Object.keys(settingsOut).length > 0 ? { settings: settingsOut } : {}),
800
831
  };
801
832
  return Object.freeze({ request: out, omitted: Object.freeze([...omitted, ...lateOmitted]) });
@@ -819,6 +850,7 @@ export function applyLiveRequestDefaults(req, host) {
819
850
  out.retainSubagentSessions = true;
820
851
  if (out.agents === undefined && host.agents !== undefined)
821
852
  out.agents = host.agents;
853
+ refuseDisableSkillsConflict(out.disableSkills, out.skills, out.agents, 'applyLiveRequestDefaults');
822
854
  if (typeof out.scenario === 'string' && host.mockScenarioKeys?.has(out.scenario)) {
823
855
  delete out.scenario;
824
856
  }
@@ -11,6 +11,11 @@ export interface RunTerminalPausedRead extends RunTerminalPause {
11
11
  code?: string;
12
12
  flatStatus?: string;
13
13
  }
14
+ export interface RunTerminalFailureDetail {
15
+ kind: string;
16
+ status?: number;
17
+ code?: string;
18
+ }
14
19
  export type RunTerminalRead = {
15
20
  kind: 'completed';
16
21
  plane: RunTerminalPlane;
@@ -20,6 +25,7 @@ export type RunTerminalRead = {
20
25
  code?: string;
21
26
  message?: string;
22
27
  nestedPause?: RunTerminalPause;
28
+ detail?: RunTerminalFailureDetail;
23
29
  } | {
24
30
  kind: 'blocked';
25
31
  plane: RunTerminalPlane;