dsh-switchman 1.11.5 → 1.11.7

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.
@@ -0,0 +1,510 @@
1
+ /** dsh-switchman PTC automatic binding ledger.
2
+ *
3
+ * Symptom (2026-11-15, log audit): 29 of 69 recent PTC sessions hit the real
4
+ * runtime rejection \`Identifier 'x' has already been declared\` (63 hits,
5
+ * concentrated on draft names r/fs/res/execSync/results/idx). Every affected
6
+ * session ALREADY carried the [SWITCHMAN:PTC] advice clauses (41 also the
7
+ * manual \`globalThis.__dshSwitchmanPtcBindings\` ledger clause), so prompt-only
8
+ * mitigation is proven ineffective: old programs fold out of the model's view
9
+ * and it cannot see what it declared rounds ago.
10
+ *
11
+ * Fix: host-side automatic bookkeeping over the outer \`run_code\` transport
12
+ * (nested SDK calls arrive under their own tool names and are ignored):
13
+ * - tools/pre-execute scans the program text for top-level declarations
14
+ * (const/let/var/using/class/function/enum/namespace) with a zero-dependency
15
+ * scanner — comments, strings, template literals, and regex literals are
16
+ * stripped first so keywords inside literals never match; bracket depth
17
+ * gates "top level" so IIFE-wrapped bodies, for-loop heads, and blocks are
18
+ * excluded — and parks the name list on the exec (WeakMap: the SAME exec
19
+ * object flows through pre- and post-execute, so parallel calls stay
20
+ * independent and denied/abandoned entries are garbage-collected).
21
+ * - tools/post-execute commits the parked names into the session ledger on
22
+ * success; on a parse-level failure nothing ran, so the parked names roll
23
+ * back — and when the error quotes a duplicate identifier, that name is
24
+ * force-added (self-heal for anything the scanner missed); on other failures
25
+ * the names commit anyway, because partial execution leaves earlier
26
+ * top-level declarations live (over-reporting is the safe direction).
27
+ *
28
+ * Injection: host/ptc-guard.js appends renderPtcLedgerClause(sessionKey) to
29
+ * its final-say section (order 10510 — re-rendered on every model step and
30
+ * never folded), listing the session's used names plus the no-redeclare rule.
31
+ * An empty ledger renders "" so a session's first program is unannotated.
32
+ *
33
+ * Lifecycle: the ledger is process-lifetime memory keyed by session id
34
+ * (agent.session.id ?? agent.id); session/disposed drops it, and a PTC
35
+ * environment-reset notice (user/message, source.kind "ptc-mode", the fixed
36
+ * reset text emitted by PtcExecutor.reset) clears it since the runtime's
37
+ * bindings are gone with the environment.
38
+ *
39
+ * Known scanner limits (documented, accepted): an unterminated-regex fallback
40
+ * treats the slash as division (keywords inside such text can over-report;
41
+ * never under-report silently — the self-heal covers real collisions);
42
+ * computed destructuring keys capture nothing. Everything fails open: a
43
+ * scanning/listener error costs the mitigation, never the tool call. Zero new
44
+ * dependencies.
45
+ */
46
+
47
+ const RUN_CODE_NAME = "run_code";
48
+
49
+ /** Session ledger: sessionKey -> Set<name> (insertion-ordered; Set preserves
50
+ * first-declaration order for display). Process-lifetime memory. */
51
+ const ledgers = new Map();
52
+
53
+ /** Parked scan result per in-flight outer run_code exec. */
54
+ const pending = new WeakMap();
55
+
56
+ /** Clause display cap: the section renders on every model step, so an
57
+ * over-long list would tax every turn; the most recent names matter most. */
58
+ const LEDGER_DISPLAY_CAP = 120;
59
+
60
+ /** Fixed prefix of the model-visible notice PtcExecutor.reset injects after
61
+ * clearing one agent's retained environment (tool-executor-ptc index.ts). */
62
+ const PTC_RESET_NOTICE = "Program environment reset; retained variables cleared.";
63
+
64
+ const DECL_KEYWORDS = new Set(["const", "let", "var", "using", "class", "function", "enum", "namespace"]);
65
+ /** Keywords whose declaration is <keyword> <ident> (no declarator list). */
66
+ const CLASS_LIKE_KEYWORDS = new Set(["class", "function", "enum", "namespace"]);
67
+
68
+ const REDECLARE_RE = /has already been declared/i;
69
+ const REDECLARE_NAME_RE = /['"]([A-Za-z_$][A-Za-z0-9_$]*)['"]\s+has already been declared/g;
70
+ /** Parse-level failures: the program never ran, so nothing it declared is live. */
71
+ const PARSE_FAIL_RE = /SyntaxError|parse error|Expected ident|Unexpected token|Unexpected identifier/i;
72
+
73
+ function isIdStart(c) {
74
+ return c !== undefined && /[A-Za-z_$]/.test(c);
75
+ }
76
+
77
+ function readWord(src, pos) {
78
+ const n = src.length;
79
+ let end = pos;
80
+ while (end < n && /[A-Za-z0-9_$]/.test(src[end])) end += 1;
81
+ return src.slice(pos, end);
82
+ }
83
+
84
+ function skipWs(src, pos) {
85
+ const n = src.length;
86
+ while (pos < n && /\s/.test(src[pos])) pos += 1;
87
+ return pos;
88
+ }
89
+
90
+ /** Blank every literal region (comments, strings, templates — except the
91
+ * code inside interpolation braces, whose braces stay balanced) so the depth
92
+ * scanner only ever sees real code. Returns a same-length string. */
93
+ function stripLiterals(src) {
94
+ const out = src.split("");
95
+ const n = src.length;
96
+ const stack = ["code"];
97
+ let i = 0;
98
+ const blank = (from, to) => {
99
+ for (let k = from; k < to; k += 1) out[k] = " ";
100
+ };
101
+ /** Regex literal vs division: look back at the last significant emitted
102
+ * char; /= is always the division-assign operator, never a regex. */
103
+ const tryRegex = (pos) => {
104
+ let j = pos - 1;
105
+ while (j >= 0 && out[j] === " ") j -= 1;
106
+ const prev = j >= 0 ? out[j] : "";
107
+ if (!(prev === "" || "(=,:[!&|?{};+*-%<>~^".includes(prev))) return pos;
108
+ if (src[pos + 1] === "=") return pos; // /= operator
109
+ let k = pos + 1;
110
+ let inClass = false;
111
+ while (k < n) {
112
+ const d = src[k];
113
+ if (d === "\\") { k += 2; continue; }
114
+ if (d === "[") { inClass = true; k += 1; continue; }
115
+ if (d === "]") { inClass = false; k += 1; continue; }
116
+ if (d === "/" && !inClass) {
117
+ blank(pos, k + 1);
118
+ return k + 1;
119
+ }
120
+ if (d === "\n") return pos; // unterminated: treat as division
121
+ k += 1;
122
+ }
123
+ return pos;
124
+ };
125
+ while (i < n) {
126
+ const frame = stack[stack.length - 1];
127
+ const c = src[i];
128
+ if (frame === "code" || frame === "interp") {
129
+ const interp = frame === "interp";
130
+ if (c === "'") { stack.push("sq"); blank(i, i + 1); i += 1; continue; }
131
+ if (c === '"') { stack.push("dq"); blank(i, i + 1); i += 1; continue; }
132
+ if (c === "\`") { stack.push("tpl"); blank(i, i + 1); i += 1; continue; }
133
+ if (c === "/" && src[i + 1] === "/") { stack.push("line"); blank(i, i + 2); i += 2; continue; }
134
+ if (c === "/" && src[i + 1] === "*") { stack.push("block"); blank(i, i + 2); i += 2; continue; }
135
+ if (c === "/") {
136
+ const next = tryRegex(i);
137
+ if (next !== i) { i = next; continue; }
138
+ i += 1; continue;
139
+ }
140
+ // Interpolation code: each "{" opens a nested frame, each "}" pops one,
141
+ // so the interpolation's closing brace returns to the template text.
142
+ if (interp && c === "{") { stack.push("interp"); i += 1; continue; }
143
+ if (interp && c === "}") { stack.pop(); i += 1; continue; }
144
+ i += 1; continue;
145
+ }
146
+ if (frame === "sq" || frame === "dq") {
147
+ if (c === "\\") { blank(i, i + 2); i += 2; continue; }
148
+ blank(i, i + 1);
149
+ if ((frame === "sq" && c === "'") || (frame === "dq" && c === '"')) stack.pop();
150
+ i += 1;
151
+ continue;
152
+ }
153
+ if (frame === "line") {
154
+ if (c === "\n") { stack.pop(); i += 1; continue; }
155
+ blank(i, i + 1); i += 1; continue;
156
+ }
157
+ if (frame === "block") {
158
+ if (c === "*" && src[i + 1] === "/") { stack.pop(); blank(i, i + 2); i += 2; continue; }
159
+ if (c !== "\n") blank(i, i + 1);
160
+ i += 1; continue;
161
+ }
162
+ if (frame === "tpl") {
163
+ if (c === "\\") { blank(i, i + 2); i += 2; continue; }
164
+ if (c === "\`") { stack.pop(); blank(i, i + 1); i += 1; continue; }
165
+ if (c === "$" && src[i + 1] === "{") {
166
+ // Interpolation: emit ONE "{" so bracket depth stays balanced with
167
+ // the interpolation's closing "}", then scan its code as code.
168
+ out[i] = "{";
169
+ blank(i + 1, i + 2);
170
+ stack.push("interp");
171
+ i += 2;
172
+ continue;
173
+ }
174
+ blank(i, i + 1); i += 1; continue;
175
+ }
176
+ }
177
+ return out.join("");
178
+ }
179
+
180
+ /** Skip one balanced bracket group starting at src[pos]; returns the index
181
+ * just past the matching closer (EOF if unbalanced). */
182
+ function skipBalanced(src, pos) {
183
+ const open = src[pos];
184
+ const close = open === "{" ? "}" : open === "[" ? "]" : ")";
185
+ const n = src.length;
186
+ let depth = 0;
187
+ let i = pos;
188
+ while (i < n) {
189
+ const c = src[i];
190
+ if (c === open) depth += 1;
191
+ else if (c === close) {
192
+ depth -= 1;
193
+ if (depth === 0) return i + 1;
194
+ }
195
+ i += 1;
196
+ }
197
+ return i;
198
+ }
199
+
200
+ /** Scan one destructuring pattern; returns captured binding names and the
201
+ * index just past the pattern. Property keys are skipped, computed keys are
202
+ * skipped balanced, defaults are skipped after =, nested patterns recurse,
203
+ * ...rest binds. */
204
+ function scanPattern(src, pos) {
205
+ const obj = src[pos] === "{";
206
+ const names = [];
207
+ const n = src.length;
208
+ let depth = 0;
209
+ let bind = true; // pattern position where a binding may start
210
+ let expectKey = obj; // object: the slot after "{" or "," holds a key
211
+ let afterEq = false;
212
+ let i = pos + 1;
213
+ while (i < n) {
214
+ const c = src[i];
215
+ if (depth === 0) {
216
+ if (c === "=") { afterEq = true; bind = false; i += 1; continue; }
217
+ if (c === ",") { afterEq = false; bind = true; expectKey = obj; i += 1; continue; }
218
+ if (obj && c === ":") { bind = true; afterEq = false; expectKey = false; i += 1; continue; }
219
+ }
220
+ if (c === "(" || c === "{" || c === "[") {
221
+ if (depth > 0) { depth += 1; i += 1; continue; }
222
+ if (obj && c === "[" && expectKey && !afterEq) {
223
+ i = skipBalanced(src, i); // computed key expression — never a binding
224
+ bind = false;
225
+ continue;
226
+ }
227
+ if (afterEq || !bind) { depth += 1; i += 1; continue; } // default-value expression
228
+ const sub = scanPattern(src, i);
229
+ for (const name of sub.names) names.push(name);
230
+ i = sub.i;
231
+ bind = false;
232
+ expectKey = false;
233
+ continue;
234
+ }
235
+ if (c === ")" || c === "}" || c === "]") {
236
+ if (depth > 0) { depth -= 1; i += 1; continue; }
237
+ if ((obj && c === "}") || (!obj && c === "]")) return { names, i: i + 1 };
238
+ i += 1; // mismatched closer: skip defensively
239
+ continue;
240
+ }
241
+ if (depth === 0 && !afterEq && isIdStart(c)) {
242
+ const word = readWord(src, i);
243
+ const j = skipWs(src, i + word.length);
244
+ if (obj && expectKey && src[j] === ":") {
245
+ i = j + 1; // property key: the binding follows the colon
246
+ bind = true;
247
+ expectKey = false;
248
+ continue;
249
+ }
250
+ if (bind) names.push(word);
251
+ i += word.length;
252
+ bind = false;
253
+ expectKey = false;
254
+ continue;
255
+ }
256
+ i += 1;
257
+ }
258
+ return { names, i };
259
+ }
260
+
261
+ /** Scan a const/let/var/using declarator list beginning just after the
262
+ * keyword; returns the resume index. Handles const a = 1, b = 2; patterns,
263
+ * ASI before the next declaration keyword, and bails cleanly on malformed
264
+ * shapes (the main scanner reprocesses the resume char). */
265
+ function scanDeclaratorList(src, pos, names) {
266
+ const n = src.length;
267
+ let i = pos;
268
+ for (;;) {
269
+ i = skipWs(src, i);
270
+ if (i >= n) return i;
271
+ const c = src[i];
272
+ if (isIdStart(c)) {
273
+ const word = readWord(src, i);
274
+ if (DECL_KEYWORDS.has(word)) return i; // ASI: next statement reached
275
+ names.push(word);
276
+ i += word.length;
277
+ } else if (c === "{" || c === "[") {
278
+ const res = scanPattern(src, i);
279
+ for (const name of res.names) names.push(name);
280
+ i = res.i;
281
+ } else {
282
+ return i; // malformed: bail to the main scanner
283
+ }
284
+ // Skip the initializer up to ";" (statement end) or a top-level ","
285
+ // (next declarator); a following declaration keyword ends the list (ASI).
286
+ let rel = 0;
287
+ while (i < n) {
288
+ const d = src[i];
289
+ if (d === "(" || d === "[" || d === "{") { rel += 1; i += 1; continue; }
290
+ if (d === ")" || d === "]" || d === "}") { if (rel > 0) rel -= 1; i += 1; continue; }
291
+ if (rel === 0) {
292
+ if (d === ";") return i + 1;
293
+ if (d === ",") { i += 1; break; }
294
+ if (isIdStart(d)) {
295
+ const word = readWord(src, i);
296
+ if (DECL_KEYWORDS.has(word)) return i; // ASI: next statement
297
+ i += word.length;
298
+ continue;
299
+ }
300
+ }
301
+ i += 1;
302
+ }
303
+ if (i >= n) return i;
304
+ }
305
+ }
306
+
307
+ /** Top-level declared names of one (already literal-stripped) program text. */
308
+ function topLevelDeclarations(src) {
309
+ if (typeof src !== "string" || src === "") return [];
310
+ const stripped = stripLiterals(src);
311
+ const names = [];
312
+ const n = stripped.length;
313
+ let depth = 0;
314
+ let i = 0;
315
+ while (i < n) {
316
+ const c = stripped[i];
317
+ if (c === "(" || c === "[" || c === "{") { depth += 1; i += 1; continue; }
318
+ if (c === ")" || c === "]" || c === "}") { if (depth > 0) depth -= 1; i += 1; continue; }
319
+ if (isIdStart(c)) {
320
+ const word = readWord(stripped, i);
321
+ i += word.length;
322
+ if (depth !== 0 || !DECL_KEYWORDS.has(word)) continue;
323
+ if (CLASS_LIKE_KEYWORDS.has(word)) {
324
+ let j = skipWs(stripped, i);
325
+ if (word === "function" && stripped[j] === "*") j = skipWs(stripped, j + 1);
326
+ if (isIdStart(stripped[j])) {
327
+ const name = readWord(stripped, j);
328
+ names.push(name);
329
+ i = j + name.length;
330
+ }
331
+ continue;
332
+ }
333
+ // const / let / var / using — peek a "const enum X" class-like form.
334
+ let j = skipWs(stripped, i);
335
+ if (isIdStart(stripped[j]) && readWord(stripped, j) === "enum") {
336
+ j = skipWs(stripped, j + 4);
337
+ if (isIdStart(stripped[j])) {
338
+ const name = readWord(stripped, j);
339
+ names.push(name);
340
+ i = j + name.length;
341
+ }
342
+ continue;
343
+ }
344
+ i = scanDeclaratorList(stripped, i, names);
345
+ continue;
346
+ }
347
+ i += 1;
348
+ }
349
+ return names;
350
+ }
351
+
352
+ /** Stable session key off an Agent or Session object: session.id first, id as
353
+ * the fallback (Agent.id IS the SessionId; Session only has id). */
354
+ function sessionKeyOf(agentLike) {
355
+ if (agentLike === null || typeof agentLike !== "object") return null;
356
+ const sid = agentLike.session?.id;
357
+ if (typeof sid === "string" && sid !== "") return sid;
358
+ const id = agentLike.id;
359
+ return typeof id === "string" && id !== "" ? id : null;
360
+ }
361
+
362
+ function ledgerOf(key) {
363
+ let set = ledgers.get(key);
364
+ if (set === undefined) {
365
+ set = new Set();
366
+ ledgers.set(key, set);
367
+ }
368
+ return set;
369
+ }
370
+
371
+ function commitNames(key, names) {
372
+ if (!Array.isArray(names) || names.length === 0) return;
373
+ const set = ledgerOf(key);
374
+ for (const name of names) {
375
+ if (typeof name === "string" && name !== "") set.add(name);
376
+ }
377
+ }
378
+
379
+ function contentTextOf(blocks) {
380
+ if (!Array.isArray(blocks)) return "";
381
+ const out = [];
382
+ for (const block of blocks) {
383
+ if (block?.type === "text" && typeof block.text === "string") out.push(block.text);
384
+ }
385
+ return out.join("\n");
386
+ }
387
+
388
+ /** Model-visible failure text: error.message plus any text content, since the
389
+ * runtime surfaces the SyntaxError in either place. */
390
+ function failureTextOf(result) {
391
+ const parts = [];
392
+ const message = result?.error?.message;
393
+ if (typeof message === "string" && message !== "") parts.push(message);
394
+ const text = contentTextOf(result?.content);
395
+ if (text !== "") parts.push(text);
396
+ return parts.length > 0 ? parts.join("\n") : null;
397
+ }
398
+
399
+ /** Self-heal: force-add every identifier the rejection quotes as already
400
+ * declared (covers names the scanner missed in earlier programs). */
401
+ function addSelfHealNames(set, text) {
402
+ REDECLARE_NAME_RE.lastIndex = 0;
403
+ for (;;) {
404
+ const match = REDECLARE_NAME_RE.exec(text);
405
+ if (match === null) break;
406
+ set.add(match[1]);
407
+ }
408
+ }
409
+
410
+ /** The ledger clause for one session, or "" when the session has no recorded
411
+ * names (first program, unknown session, disabled state). Byte-stable apart
412
+ * from the name list itself. */
413
+ function renderPtcLedgerClause(sessionKey) {
414
+ try {
415
+ if (sessionKey === null) return "";
416
+ const set = ledgers.get(sessionKey);
417
+ if (set === undefined || set.size === 0) return "";
418
+ const all = [...set];
419
+ const shown = all.slice(-LEDGER_DISPLAY_CAP);
420
+ const noun = all.length === 1 ? "1 top-level binding" : String(all.length) + " top-level bindings";
421
+ const head = shown.length < all.length
422
+ ? noun + " already declared by earlier run_code programs in THIS session (host-tracked; showing the most recent " + shown.length + ")"
423
+ : noun + " already declared by earlier run_code programs in THIS session (host-tracked)";
424
+ return [
425
+ "[SWITCHMAN:PTC-LEDGER] " + head + ": " + shown.join(", ") + ".",
426
+ "- Never redeclare these names at the top level (const/let/var/using/class/function/enum/namespace): reuse the existing binding, reassign it, or keep new code inside an async IIFE where fresh local names are always safe.",
427
+ "- If you still hit the has-already-been-declared rejection, rewrite the program in the IIFE form instead of rerunning it.",
428
+ ].join("\n");
429
+ } catch {
430
+ return "";
431
+ }
432
+ }
433
+
434
+ function clearSessionLedger(sessionKey) {
435
+ if (typeof sessionKey === "string" && sessionKey !== "") ledgers.delete(sessionKey);
436
+ }
437
+
438
+ /** Mount the ledger: two tools-waterfall listeners plus session lifecycle
439
+ * cleanup. Every listener fails open (best-effort, then next()). */
440
+ function applyPtcBindings(ctx) {
441
+ ctx.on("tools/pre-execute", (exec, next) => {
442
+ try {
443
+ if (exec?.name === RUN_CODE_NAME && typeof exec?.arguments?.code === "string") {
444
+ const key = sessionKeyOf(exec?.agent);
445
+ if (key !== null) {
446
+ pending.set(exec, { key, names: topLevelDeclarations(exec.arguments.code) });
447
+ }
448
+ }
449
+ } catch {
450
+ /* fail open: the call proceeds unscanned */
451
+ }
452
+ return next();
453
+ });
454
+ ctx.on("tools/post-execute", (exec, result, next) => {
455
+ try {
456
+ if (exec?.name === RUN_CODE_NAME) {
457
+ const rec = pending.get(exec);
458
+ pending.delete(exec);
459
+ const key = rec !== undefined ? rec.key : sessionKeyOf(exec?.agent);
460
+ if (key !== null) {
461
+ if (result?.isError === true) {
462
+ const text = failureTextOf(result);
463
+ if (text !== null && REDECLARE_RE.test(text)) {
464
+ // Parse-level rejection before any code ran: roll back the
465
+ // parked names, then self-heal the quoted duplicate(s).
466
+ addSelfHealNames(ledgerOf(key), text);
467
+ } else if (text === null || !PARSE_FAIL_RE.test(text)) {
468
+ // Runtime failure: earlier top-level declarations are live.
469
+ commitNames(key, rec?.names);
470
+ }
471
+ // else: other parse failure — nothing ran, nothing to record.
472
+ } else {
473
+ commitNames(key, rec?.names);
474
+ }
475
+ }
476
+ }
477
+ } catch {
478
+ /* fail open */
479
+ }
480
+ return next();
481
+ });
482
+ ctx.on("session/event", (session, event) => {
483
+ try {
484
+ if (event?.type !== "user/message") return;
485
+ if (event?.data?.source?.kind !== "ptc-mode") return;
486
+ const text = contentTextOf(event?.data?.content);
487
+ if (typeof text === "string" && text.includes(PTC_RESET_NOTICE)) {
488
+ clearSessionLedger(sessionKeyOf(session));
489
+ }
490
+ } catch {
491
+ /* fail open */
492
+ }
493
+ });
494
+ ctx.on("session/disposed", (session) => {
495
+ try {
496
+ clearSessionLedger(sessionKeyOf(session));
497
+ } catch {
498
+ /* fail open */
499
+ }
500
+ });
501
+ }
502
+
503
+ export {
504
+ applyPtcBindings,
505
+ clearSessionLedger,
506
+ renderPtcLedgerClause,
507
+ sessionKeyOf,
508
+ stripLiterals,
509
+ topLevelDeclarations,
510
+ };
package/host/ptc-guard.js CHANGED
@@ -43,6 +43,8 @@
43
43
  /** Reserved transport-tool name the PTC executor declares as its only direct call. */
44
44
  const RUN_CODE_NAME = "run_code";
45
45
 
46
+ import { renderPtcLedgerClause, sessionKeyOf } from "./ptc-bindings.js";
47
+
46
48
  /** Final-say section position: after context-watch (10500), the last existing section. */
47
49
  const PTC_GUARD_ORDER = 10510;
48
50
 
@@ -109,7 +111,13 @@ function applyPtcGuard(ctx) {
109
111
  interpolate: false,
110
112
  text: (context) => {
111
113
  try {
112
- return isPtcScope(ctx, context?.scope) ? renderPtcGuard() : "";
114
+ if (!isPtcScope(ctx, context?.scope)) return "";
115
+ // Automatic binding ledger (host/ptc-bindings.js): when this
116
+ // session has recorded top-level declarations, append the
117
+ // no-redeclare list as the last word. No agent on the assemble
118
+ // context (fake scopes, diagnostics) renders the guard alone.
119
+ const clause = renderPtcLedgerClause(sessionKeyOf(context?.agent));
120
+ return clause === "" ? renderPtcGuard() : renderPtcGuard() + "\n\n" + clause;
113
121
  } catch {
114
122
  return "";
115
123
  }