@passioncode-ai/passioncode 0.2.45 → 0.2.46

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 (56) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/family.json +1 -1
  3. package/package.json +1 -1
  4. package/payload/.claude-plugin/marketplace.json +2 -2
  5. package/payload/manifest.json +8 -8
  6. package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  7. package/payload/plugins/fabric-agent-adapter/hooks/hooks.json +1 -1
  8. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
  9. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +5 -1
  10. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +73 -18
  11. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +6 -5
  12. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +70 -20
  13. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +7 -1
  14. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +9 -0
  15. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +14 -5
  16. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +1 -0
  17. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +1 -1
  18. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +4 -1
  19. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +16 -9
  20. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +87 -156
  21. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +180 -0
  22. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +1 -0
  23. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +4 -2
  24. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +2 -0
  25. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +15 -3
  26. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +2 -0
  27. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +12 -1
  28. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +12 -1
  29. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +1 -0
  30. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +32 -13
  31. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +115 -147
  32. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +173 -0
  33. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +9 -42
  34. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +53 -4
  35. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +7 -3
  36. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +4 -2
  37. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +87 -11
  38. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +19 -8
  39. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +30 -6
  40. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +9 -1
  41. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +15 -1
  42. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +73 -18
  43. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +1 -1
  44. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +134 -24
  45. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +155 -21
  46. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
  47. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +10 -10
  48. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +2 -1
  49. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +39 -13
  50. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +6 -2
  51. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +17 -3
  52. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +23 -6
  53. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +10 -39
  54. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_paths.py +190 -0
  55. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +1 -1
  56. package/payload/plugins/passioncode/.claude-plugin/plugin.json +1 -1
@@ -0,0 +1,180 @@
1
+ // The board responder's slot records (FAA-155, FAA-163): the generation it holds each slot at, a move to another
2
+ // session and its take-back, and a capability whose enroll was refused, retried on its own. Part of the board
3
+ // connector (FAA-65, layer L4); Responder (fabric-board-session.mjs) extends SlotKeeper.
4
+ import { BoardError, LEASE_SECONDS, isInt, isObj } from './fabric-board-shapes.mjs';
5
+
6
+ // The answer may have been stored: send it again with the same key (BoardError).
7
+ export const LOST = ['unreachable', 'session-ended'];
8
+
9
+ /** A lost call is sent again this long before the lease it keeps would lapse (FAA-77, finding 38). */
10
+ export const LEASE_MARGIN_SECONDS = 10;
11
+
12
+ /** How long a held slot waits before it asks again: doubling from the poll interval, never past this. */
13
+ export const HOLD_RETRY_MAX_SECONDS = 900;
14
+
15
+ /** Refusals that are about the board session, not one capability: they end recovery and go to the responder's fault. */
16
+ export const SESSION_CODES = Object.freeze(new Set(['unreachable', 'session-ended', 'not_authorized', 'authority_changed', 'credential', 'no-epoch',
17
+ 'schema-unsupported', 'checkpoint', 'receipt-shape', 'idempotency_conflict', 'not-running', 'not-a-hub', 'hub-unavailable', 'protocol', 'not_available']));
18
+
19
+ /** The slot methods of Responder, which owns every field they read (checkpoint, slots, project, locked). */
20
+ export class SlotKeeper {
21
+ // -- a slot moved to another session (FAA-155, DEC-0140 PR-2) --
22
+ /** The generation this responder holds the slot at, kept beside its checkpoint; a failed write is retried at the next receipt. */
23
+ heldAt(capability, generation) {
24
+ this.note(capability, { project: this.project, generation });
25
+ this.confirmed[capability] = generation;
26
+ delete this.said[`board.slot.refused:${capability}`]; // held again: a later refusal is said again
27
+ delete this.said[`board.slot.contested:${capability}`]; // and a later contest, by enroll or replace
28
+ }
29
+
30
+ /** The slot record, best effort: one that cannot be written now is written at the next receipt. */
31
+ note(capability, record) {
32
+ try {
33
+ this.checkpoint.slot(capability, record);
34
+ } catch {
35
+ // the request steps fail closed on their own
36
+ }
37
+ }
38
+
39
+ /**
40
+ * An enroll or replace is recorded with its key before it is sent, so a lost answer is never read as a move; a refusal
41
+ * drops the record, a receipt replaces it (heldAt), a record that cannot be written is skipped.
42
+ */
43
+ async ask(capability, op, args) {
44
+ let held = this.checkpoint.slots[capability] ?? {};
45
+ if (held.project !== this.project) return this.call(op, args); // no record: nothing a lost answer could be mistaken for
46
+ const slot = this.slots[capability] ?? {};
47
+ if (op === 'com.enroll' && slot.state === 'held' && slot.generation === held.generation
48
+ && this.now() - (slot.renewedAt ?? 0) < LEASE_SECONDS - LEASE_MARGIN_SECONDS && !('asking' in held)) {
49
+ return this.call(op, args); // a live holder's renew cannot change its generation: nothing to record
50
+ }
51
+ if (isObj(held.asking)) {
52
+ // An earlier lost ask is settled first; it may show the slot moved.
53
+ if (await this.gone(capability)) throw new BoardError('fenced', `The ${capability} slot moved to another session.`, false, op);
54
+ held = this.checkpoint.slots[capability] ?? {};
55
+ }
56
+ try {
57
+ this.checkpoint.slot(capability, { ...held, asking: { op, args } });
58
+ } catch {
59
+ return this.call(op, args);
60
+ }
61
+ try {
62
+ return await this.call(op, args);
63
+ } catch (e) {
64
+ if (!LOST.includes(e.code)) this.note(capability, held);
65
+ throw e;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Asks a lost enroll or replace again under its key. `repeated: true`: it landed, the generation is ours. Else it ran
71
+ * only now: if the slot had moved past the record (`before`), the record stays and the move is read (the slot just
72
+ * taken lapses unused). A refusal or an odd receipt drops the record; a lost answer keeps it.
73
+ */
74
+ async replay(capability, held, before) {
75
+ const { asking, ...rest } = held;
76
+ let record = rest;
77
+ if (isObj(asking) && ['com.enroll', 'com.responder_replace'].includes(asking.op)) {
78
+ try {
79
+ const answer = this.receipt(asking.op, await this.call(asking.op, asking.args));
80
+ const generation = answer.slot?.generation;
81
+ const moved = isInt(before, 1) && isInt(rest.generation, 1) && before > rest.generation;
82
+ if (isInt(generation, 1) && (answer.repeated === true || !moved)) record = { project: this.project, generation };
83
+ } catch (e) {
84
+ if (LOST.includes(e.code)) throw e;
85
+ if (e.code === 'idempotency_window_expired') record = {}; // a retired epoch: unknown, so a first start, no move
86
+ }
87
+ }
88
+ this.note(capability, Object.keys(record).length ? record : null);
89
+ return record;
90
+ }
91
+
92
+ /**
93
+ * True when com.status shows the slot's generation past the one recorded held: the operator or another session moved
94
+ * it. The slot is left `moved`. No record, another Project's, an odd value or no later generation: false.
95
+ */
96
+ async gone(capability, status = null) {
97
+ let held = this.checkpoint.slots[capability] ?? {};
98
+ if (held.project !== this.project || (!isInt(held.generation, 1) && !('asking' in held))) return false;
99
+ let now = ((status ?? await this.status()).responders ?? []).find((r) => isObj(r) && r.capability === capability)?.generation;
100
+ if ('asking' in held) held = await this.replay(capability, held, now); // its own lost ask first
101
+ if (isInt(held.moved, 1) /* a recorded move stays until takeBack */ && !(isInt(now, 1) && now > (held.generation ?? now))) now = held.moved;
102
+ if (!isInt(now, 1) || !isInt(held.generation, 1) || now <= held.generation) {
103
+ if (isInt(now, 1) && isInt(held.generation, 1) && !('moved' in held)) this.confirmed[capability] = held.generation; // com.status shows it unmoved (0.56.0 seam r3)
104
+ return false;
105
+ }
106
+ // On the fence queue (FAA-158): a fenced call in flight lands first, and a takeBack meanwhile wins.
107
+ return this.locked(() => this.moved(capability, held, now));
108
+ }
109
+
110
+ /** The slot moved past `held` to `now`: stop its work, keep its accepted requests held, say it once. */
111
+ moved(capability, held, now) {
112
+ const record = this.checkpoint.slots[capability] ?? {};
113
+ if (record.project !== this.project || record.generation !== held.generation) return false; // a takeBack forgot it
114
+ if (this.current?.entry.capability === capability) {
115
+ this.current.ctx.stopped = true;
116
+ this.current = null;
117
+ }
118
+ for (const entry of Object.values(this.checkpoint.entries).filter((e) => e.capability === capability).map((e) => ({ ...e }))) {
119
+ if (entry.step === 'claimed') this.checkpoint.drop(entry.request); // the move returned the claim to the queue
120
+ else if (!['unknown', 'lapsed'].includes(entry.step)) {
121
+ // Begun: outcome_unknown, which the slot's holder reconciles. Accepted: held on the board, continued only by a
122
+ // transfer if the slot is this service's again; never a com.responder_replace on its own.
123
+ entry.step = entry.effect ? 'unknown' : 'lapsed';
124
+ this.checkpoint.put(entry);
125
+ }
126
+ }
127
+ this.toResume = this.toResume.filter((r) => this.checkpoint.entries[r]?.capability !== capability);
128
+ this.slots[capability] = { state: 'moved', generation: now, renewedAt: this.now() };
129
+ delete this.confirmed[capability];
130
+ if (!('moved' in held)) { // once, even across restarts
131
+ this.emit('board.slot.moved', 'warning', `The ${capability} slot on this Project moved to another session. This service stopped answering it there and left its accepted work \`accepted\` (work begun is the new holder's to settle). Its take-back action brings the slot back once the other session lets it go, or at a later restart or reconnect while accepted work waits; a restart alone does not. Without it, ask its maintainer.`, { notify: true, episode: capability, capability, generation: now, held: held.generation });
132
+ this.note(capability, { ...held, moved: now }); // said again at the next start if it cannot be written
133
+ }
134
+ return true;
135
+ }
136
+
137
+ /**
138
+ * The operator's act, never the kit's: forget that a capability's slot moved away. In the same board session the next
139
+ * tick enrolls once the slot is free; a reconnect with held work takes it back at once by a transfer (FAA-155).
140
+ */
141
+ takeBack(capability) {
142
+ if (!this.capabilities.includes(capability)) throw new BoardError('invalid_arguments', `This responder does not answer ${capability}.`);
143
+ this.checkpoint.slot(capability, null);
144
+ if (this.slots[capability]?.state === 'moved') this.slots[capability] = { state: 'contested', renewedAt: 0 };
145
+ delete this.said['board.slot.moved'];
146
+ }
147
+
148
+ // -- one capability's refusal (FAA-163) --
149
+ /**
150
+ * An enroll or replace of one capability was refused: its accepted work waits `lapsed` (a transfer takes it back once
151
+ * the slot is held) and the capability asks again after a doubling pause; the others go on.
152
+ */
153
+ retryLater(capability, entries, e) {
154
+ if (this.slots[capability]?.state === 'held') return; // the replace landed: what failed came after it
155
+ for (const entry of entries) {
156
+ if (!['unknown', 'lapsed'].includes(entry.step)) {
157
+ entry.step = 'lapsed';
158
+ this.checkpoint.put(entry);
159
+ }
160
+ }
161
+ const slot = this.slots[capability] ?? {};
162
+ const tries = slot.state === 'retry' ? (slot.tries ?? 0) + 1 : 1;
163
+ const wait = Math.min(HOLD_RETRY_MAX_SECONDS, this.holdDelay * 2 ** (tries - 1));
164
+ this.slots[capability] = { state: 'retry', tries, retryAt: this.now() + wait, renewedAt: this.now() };
165
+ if (!SESSION_CODES.has(e.code)) { // once per episode (saidAs), also after a silent session refusal // a refusal of the board session is the fault's to say
166
+ this.emit('board.slot.refused', 'warning', `Fabric refused to give this service the ${capability} slot (${e.code}); it asks again later. Its other capabilities go on.`, { episode: capability, saidAs: `board.slot.refused:${capability}`, capability, reason: e.code });
167
+ }
168
+ }
169
+
170
+ /** Runs one capability's enroll or replace (FAA-163): a refusal of the board session is raised after the capability is left to retry. */
171
+ async settle(capability, act, entries = []) {
172
+ try {
173
+ await act();
174
+ } catch (e) {
175
+ if (!(e instanceof BoardError)) throw e;
176
+ this.retryLater(capability, entries, e);
177
+ if (SESSION_CODES.has(e.code)) throw e;
178
+ }
179
+ }
180
+ }
@@ -31,6 +31,7 @@ export {
31
31
  EVENT_SCHEMA,
32
32
  STOP_CODES,
33
33
  HOLD_RETRY_MAX_SECONDS,
34
+ IDLE_LEAVE_SECONDS,
34
35
  BoardSession,
35
36
  Checkpoint,
36
37
  BoardRequest,
@@ -140,7 +140,9 @@ function authorityOk(uri) {
140
140
  const port = m[2];
141
141
  return Boolean(m[1]) && (port === undefined || port === '' || (/^[0-9]{1,5}$/.test(port) && Number(port) <= 65535));
142
142
  }
143
- const URI_PASSWORD = /^[A-Za-z][A-Za-z0-9+.-]*:\/\/[^/?#@]*:[^/?#@]*@/;
143
+ // A special scheme reads a user:password@ after any run of slashes, none included, as `new URL` does (0.56.0 seam r7).
144
+ // To the last @, a user name may hold one; any other scheme keeps a backslash in the user information (seam r8).
145
+ const URI_PASSWORD = /^(?:(?:[Hh][Tt][Tt][Pp][Ss]?|[Ww][Ss][Ss]?|[Ff][Tt][Pp]):[/\\]*[^/\\?#]*:[^/\\?#]*@|(?![Hh][Tt][Tt][Pp][Ss]?:|[Ww][Ss][Ss]?:|[Ff][Tt][Pp]:)[A-Za-z][A-Za-z0-9+.-]*:\/\/[^/?#]*:[^/?#]*@)/;
144
146
  const RESIDENCY = /^(A[B-Z]|[B-PR-WY][A-Z]|Q[A-L]|Z[A-Y])$/;
145
147
 
146
148
  const RUNNER_KIND_NAME = /^[a-z][a-z0-9-]{1,62}$/;
@@ -190,8 +192,8 @@ export function descriptorMemberProblems(d) {
190
192
  if ('repository' in source) {
191
193
  const repository = source.repository;
192
194
  if (!(typeof repository === 'string' && SOURCE_URI.test(repository.replace(IP_LITERAL, '$1h')))) problems.push('source.repository is a URI (RFC 3986), such as https://github.com/owner/name');
195
+ else if (URI_PASSWORD.test(repository)) problems.push('source.repository carries no password: it names the repository, never a credential'); // before the host
193
196
  else if (!authorityOk(repository)) problems.push('source.repository names a host, and a port of digits up to 65535 when it has one');
194
- else if (URI_PASSWORD.test(repository)) problems.push('source.repository carries no password: it names the repository, never a credential');
195
197
  places.push(['/source/repository', repository]);
196
198
  }
197
199
  }
@@ -54,6 +54,8 @@ export class Response {
54
54
  export function redactUrl(url) {
55
55
  let parts;
56
56
  try { parts = new URL(String(url)); } catch { return '<unparseable url>'; }
57
+ // Only http and https are sent; any other scheme keeps no more than its name (0.56.0 product r10).
58
+ if (parts.protocol !== 'http:' && parts.protocol !== 'https:') return `${parts.protocol}<redacted>`;
57
59
  return `${parts.protocol}//${parts.host}${parts.pathname}`;
58
60
  }
59
61
 
@@ -230,13 +230,16 @@ const UNREADABLE_JOB_CAPABILITY = "The operator's mandate cannot be read for thi
230
230
  * covered, or saying the mandate expired or cannot be read; never the operator's words or `source`; with `fault`
231
231
  * mandate-capability-missing beside it when the job's capability cannot read the step, MJ-1); or {covered: false,
232
232
  * message: null} with no mandate or a T2 caller's (MD-5), with `fault` when the service's own clock does not read.
233
+ * Undefined and null are no mandate: a call that carried `_meta.mandate: null` passes `{}`, so it reads as
234
+ * mandate-malformed here as it does at the answer (FAA-178).
233
235
  */
234
236
  export function jobStep({ mandate, tier, now, self, capability, key, effect, callerAgent, spendUsd, project } = {}) {
235
237
  if (mandate === undefined || mandate === null) return { covered: false, message: null };
236
238
  if (!EFFECT_CLASSES.includes(effect)) throw new InteropError(`A job's approval step names its effect class (one of ${EFFECT_CLASSES.join(', ')}; DEC-0125).`);
237
239
  const call = { capability, action: key, effect };
238
- if (typeof spendUsd === 'number') call.spendUsd = spendUsd;
239
- if (typeof project === 'string') call.project = project;
240
+ const spend = mandateSpend(spendUsd); // read as actionGate reads it, so the start and the answer agree (FAA-139)
241
+ if (spend !== undefined) call.spendUsd = spend;
242
+ if (project !== undefined && project !== null) call.project = typeof project === 'string' ? project : UNREADABLE_PROJECT;
240
243
  const covers = { mandate, tier, now, self, call };
241
244
  if (typeof callerAgent === 'string' && callerAgent.length > 0) covers.callerAgent = callerAgent;
242
245
  const answer = mandateCovers(covers);
@@ -271,6 +274,14 @@ export function jobStep({ mandate, tier, now, self, capability, key, effect, cal
271
274
  * mandateCapability: a job's kept step's capability the mandate is read under (DEC-0141 MJ-1; the service's own input,
272
275
  * never a caller's); unusable, the refusal is operator-decision-required.
273
276
  */
277
+ /** The project of a call whose project could not be read: never in a mandate's projects (FAA-139). */
278
+ export const UNREADABLE_PROJECT = '\u0000unreadable';
279
+
280
+ /** A spend estimate as a mandate reads it (FAA-139): a finite number stays, anything else is none (a ceiling refuses it). */
281
+ export function mandateSpend(value) {
282
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
283
+ }
284
+
274
285
  export function actionGate({ action, tier, caller, meta, target, elicited = false, approval, actionDigest, humanStepUrl, now, self, effect, callerAgent, spendUsd, project, mandateCapability } = {}) {
275
286
  const inputs = { action, tier, caller, elicited: elicited === true, now };
276
287
  if (isObject(meta) && Object.hasOwn(meta, 'decision')) inputs.decision = meta.decision;
@@ -279,7 +290,8 @@ export function actionGate({ action, tier, caller, meta, target, elicited = fals
279
290
  }
280
291
  if (self !== undefined && self !== null && isObject(meta) && Object.hasOwn(meta, 'mandate')) {
281
292
  Object.assign(inputs, { mandate: meta.mandate, self });
282
- for (const [name, value] of [['effect', effect], ['callerAgent', callerAgent], ['spendUsd', spendUsd], ['project', project], ['mandateCapability', mandateCapability]]) {
293
+ const read = project === undefined || project === null || typeof project === 'string' ? project : UNREADABLE_PROJECT; // fail closed (FAA-139)
294
+ for (const [name, value] of [['effect', effect], ['callerAgent', callerAgent], ['spendUsd', mandateSpend(spendUsd)], ['project', read], ['mandateCapability', mandateCapability]]) {
283
295
  if (value !== undefined && value !== null) inputs[name] = value;
284
296
  }
285
297
  }
@@ -40,6 +40,8 @@ export {
40
40
  requestMeta,
41
41
  jobStep,
42
42
  actionGate,
43
+ mandateSpend,
44
+ UNREADABLE_PROJECT,
43
45
  actionRefusalToolResult,
44
46
  usageFindings,
45
47
  usageToward,
@@ -100,11 +100,22 @@ export class Client {
100
100
  if (!Number.isInteger(retries) || retries < 0 || retries > 5) {
101
101
  throw new OpenRouterError('retries is an integer from 0 to 5', { kind: 'config' });
102
102
  }
103
+ // A query or fragment would take `/chat/completions` (0.56.0 unit r19, FAA-187 (17)); refused before checkUrl, as
104
+ // the Python twin does, so a base checkUrl would also refuse is kind config in both (unit r27).
105
+ if (typeof baseUrl !== 'string') { // as Python's check_url answers a base that is not a string (unit r28)
106
+ throw new http.HttpError('the URL does not parse', { url: '<unparseable url>', kind: 'refused' });
107
+ }
108
+ if (/[?#]/.test(baseUrl)) {
109
+ throw new OpenRouterError('the base URL holds no query or fragment', { kind: 'config' });
110
+ }
103
111
  http.checkUrl(baseUrl);
104
112
  checkAttribution(projectId, undefined);
105
113
  Object.assign(this, { ceilingUsd, ledger, keyEnv, window, attribution, projectId, unmeasuredCallUsd, deadlineS, retries });
106
114
  this.prices = Object.fromEntries(Object.entries(prices ?? {}).map(([k, v]) => [k, { ...v }]));
107
- this.baseUrl = baseUrl.replace(/\/+$/, '');
115
+ // The base URL as checkUrl read it: `new URL` removes tabs and line breaks anywhere and trims C0 controls and
116
+ // spaces from its ends, so `.../v1/ ` and `.../v1\n/` are composed as `.../v1/chat/completions`, as `new URL`
117
+ // composed them in 0.55.0 (0.56.0 product r20, unit r22, seam r22; the Python twin refuses what urllib cannot send).
118
+ this.baseUrl = baseUrl.replace(/[\t\n\r]/g, '').replace(/^[\x00-\x20]+|[\x00-\x20]+$/g, '').replace(/\/+$/, '');
108
119
  this.env = env;
109
120
  this.transport = transport;
110
121
  this.sleep = sleep ?? ((s) => new Promise((resolve) => setTimeout(resolve, s * 1000)));
@@ -106,6 +106,17 @@ export function feedDataFindings(page) {
106
106
  return findings;
107
107
  }
108
108
 
109
+ /** The first place under a member that breaks the bound, for the refusal (FAA-122): `cited[0]` nests an object. */
110
+ function tooDeep(key, member) {
111
+ const entries = Array.isArray(member) ? member.map((leaf, i) => [`${key}[${i}]`, leaf]) : isPlain(member) ? Object.entries(member).map(([inner, leaf]) => [`${key}.${inner}`, leaf]) : [];
112
+ if (entries.length > 32) return `: it has ${entries.length} entries`;
113
+ const bad = entries.find(([, leaf]) => !scalar(leaf));
114
+ if (!bad) return '';
115
+ const leaf = bad[1];
116
+ const what = typeof leaf === 'string' ? 'a string over 500 characters' : Array.isArray(leaf) ? 'a list' : isPlain(leaf) ? 'an object' : 'not a scalar';
117
+ return `: data.value.${bad[0]} is ${what}; nest two levels at most (flatten it into lists or members of their own)`;
118
+ }
119
+
109
120
  /** DEC-0070's `data {schema, value}` as the schema bounds it, then FAC-SEM-101. */
110
121
  export function feedDataProblems(data) {
111
122
  if (!isPlain(data) || Object.keys(data).length !== 2 || !('schema' in data) || !('value' in data)) return ['data is {schema, value}'];
@@ -117,7 +128,7 @@ export function feedDataProblems(data) {
117
128
  if (scalar(member)) continue;
118
129
  if (Array.isArray(member) && member.length <= 32 && member.every(scalar)) continue;
119
130
  if (isPlain(member) && Object.keys(member).length <= 32 && Object.values(member).every(scalar)) continue;
120
- problems.push(`data.value.${key} is a scalar (a string of at most 500 characters), a list of at most 32 scalars or an object of at most 32 scalar members`);
131
+ problems.push(`data.value.${key} is a scalar (a string of at most 500 characters), a list of at most 32 scalars or an object of at most 32 scalar members${tooDeep(key, member)}`);
121
132
  }
122
133
  if (problems.length) return problems;
123
134
  return feedDataFindings({ events: [{ data }] }).map((f) => `FAC-SEM-101: ${f.instancePath.replace('/events/0', '')} ${f.message}`);
@@ -54,6 +54,7 @@ from fabric_board_session import ( # noqa: F401
54
54
  STOP_CODES,
55
55
  TERMINAL,
56
56
  HOLD_RETRY_MAX_SECONDS,
57
+ IDLE_LEAVE_SECONDS,
57
58
  BoardSession,
58
59
  Checkpoint,
59
60
  BoardRequest,
@@ -30,15 +30,18 @@ class BoardSession:
30
30
  DELETE, which releases every slot it holds (DEC-0073 §5). Thread-safe; the bearer never leaves the headers."""
31
31
 
32
32
  def __init__(self, grant: _hub.BoardGrant, *, timeout: float = 15.0, post: Callable[..., Any] = _hub.post_mcp):
33
- self.grant, self.timeout, self.post = grant, timeout, post
33
+ # not above 0 is the default: the transports read 0 in opposite ways (seam r8)
34
+ self.grant, self.timeout, self.post = grant, timeout if timeout is not None and timeout > 0 else 15.0, post
34
35
  self.mcp_session: Optional[str] = None
35
36
  self.protocol_version = _hub.MCP_PROTOCOL_VERSION
36
37
  self._lock = threading.Lock()
37
38
  self._rpc = 0
38
39
 
39
- def _send(self, body: Dict[str, Any], method: str = "POST"):
40
+ def _send(self, body: Dict[str, Any], method: str = "POST", timeout: Optional[float] = None):
40
41
  try:
41
- return self.post(self.grant.origin, body, self.grant.bearer(), session=self.mcp_session, timeout=self.timeout,
42
+ return self.post(self.grant.origin, body, self.grant.bearer(), session=self.mcp_session,
43
+ # none or not above 0 is the session's own: the transports read 0 in opposite ways
44
+ timeout=timeout if timeout is not None and timeout > 0 else self.timeout,
42
45
  method=method, protocol_version=self.protocol_version if self.mcp_session else None)
43
46
  except _hub.HubError as exc:
44
47
  raise BoardError("unreachable", "Fabric's hub did not answer; a write may have been stored, so send it again with "
@@ -83,15 +86,21 @@ class BoardSession:
83
86
  raise BoardError(e["code"], e["message"], e["retryable"], op=tool)
84
87
  return value
85
88
 
86
- def close(self) -> None:
89
+ def close(self, timeout: Optional[float] = None) -> bool:
90
+ """Sends the DELETE (waiting `timeout` seconds at most when above 0, else the session's); True when Fabric answered it with a
91
+ 2xx, so the slots are free now. False otherwise, a 404 (the session was already gone) included: a move made
92
+ before it ended may be read only at the next com.status, so the slot records stay; the lease lapses in 60 s."""
87
93
  with self._lock:
88
94
  if self.mcp_session is None:
89
- return
95
+ return False
90
96
  try:
91
- self._send({}, method="DELETE")
92
- except BoardError:
93
- pass # best effort: the lease lapses in 60 s anyway
94
- self.mcp_session = None
97
+ answer = self._send({}, method="DELETE", timeout=timeout)
98
+ status = answer[0] if isinstance(answer, (tuple, list)) and answer and isinstance(answer[0], int) else 0
99
+ except Exception: # noqa: BLE001 - any failure is no release, as the Node twin's catch reads it
100
+ status = 0
101
+ finally:
102
+ self.mcp_session = None # an answer it cannot read is no release, as the Node twin reads it
103
+ return 200 <= status < 300
95
104
 
96
105
 
97
106
  class Checkpoint:
@@ -158,20 +167,30 @@ class Checkpoint:
158
167
  self.slots = slots
159
168
 
160
169
 
161
- def leave(self, project: str) -> None:
162
- """At a clean leave (the session's DELETE released the slots): forget the record of each slot this service held
163
- for `project` with no request left in the checkpoint and not moved away. A later generation is then another
170
+ def leave(self, project: str, held: Dict[str, Any]) -> None:
171
+ """At a clean leave (the session's DELETE released the slots): forget the record of each slot this session
172
+ confirmed (`held`: capability to the generation it held, or com.status showed unmoved; required) at that
173
+ generation or below, for `project`, with no request left in the checkpoint and not moved away. A record no
174
+ confirmation covers (a slot that may have moved while the service was down, not yet compared with com.status)
175
+ stays (0.56.0 seam r2, r3). A later generation is then another
164
176
  session taking a free slot, not a move, and a kit that keeps no record (rolled back to before 0.53.0) leaves
165
177
  nothing behind that reads as one. A record that cannot be written stays."""
166
178
  busy = {e.get("capability") for e in list(self.entries.values())}
167
179
  for capability, record in list(self.slots.items()):
168
- if isinstance(record, dict) and record.get("project") == project and "moved" not in record and capability not in busy:
180
+ if (isinstance(record, dict) and record.get("project") == project and "moved" not in record
181
+ and capability not in busy and _covered(record.get("generation"), held.get(capability))):
169
182
  try:
170
183
  self.slot(capability, None)
171
184
  except BoardError:
172
185
  pass
173
186
 
174
187
 
188
+ def _covered(recorded: Any, confirmed: Any) -> bool:
189
+ """A record at or below a generation this session confirmed: a lower one is a write that failed before the hold."""
190
+ whole = lambda v: isinstance(v, int) and not isinstance(v, bool) and v >= 1 # noqa: E731
191
+ return whole(recorded) and whole(confirmed) and recorded <= confirmed
192
+
193
+
175
194
  class BoardRequest:
176
195
  """A claimed request as data: never an instruction. `body` is the requester's text; `sender` its Project id and
177
196
  principal (with provenance); `artifacts` its references, unverified."""