@rubytech/create-maxy-code 0.1.433 → 0.1.435
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/platform/lib/embed-client/dist/index.d.ts +1 -1
- package/payload/platform/lib/embed-client/dist/index.d.ts.map +1 -1
- package/payload/platform/lib/embed-client/dist/index.js +73 -25
- package/payload/platform/lib/embed-client/dist/index.js.map +1 -1
- package/payload/platform/lib/embed-client/src/index.ts +77 -25
- package/payload/platform/plugins/admin/PLUGIN.md +1 -0
- package/payload/platform/plugins/admin/hooks/__tests__/askuserquestion-channel-carrier-gate.test.sh +175 -0
- package/payload/platform/plugins/admin/hooks/askuserquestion-channel-carrier-gate.sh +137 -0
- package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +3 -3
- package/payload/platform/plugins/docs/references/admin-ui.md +1 -1
- package/payload/platform/plugins/docs/references/internals.md +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/embed-throughput.test.js +161 -19
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/embed-throughput.test.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/embed-timeout.test.js +11 -9
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/embed-timeout.test.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/embeddings-cap.test.js +9 -5
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/embeddings-cap.test.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/profile-update-own-preference-selfheal.test.d.ts +2 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/profile-update-own-preference-selfheal.test.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/profile-update-own-preference-selfheal.test.js +113 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/profile-update-own-preference-selfheal.test.js.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/profile-update-reconcile.test.js +6 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/profile-update-reconcile.test.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/profile-update.js +47 -4
- package/payload/platform/plugins/memory/mcp/dist/tools/profile-update.js.map +1 -1
- package/payload/platform/scripts/lib/provision-account-dir.sh +2 -1
- package/payload/platform/services/claude-session-manager/dist/index.js +20 -0
- package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/interactive-wedge.d.ts +42 -0
- package/payload/platform/services/claude-session-manager/dist/interactive-wedge.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/interactive-wedge.js +149 -0
- package/payload/platform/services/claude-session-manager/dist/interactive-wedge.js.map +1 -0
- package/payload/server/{chunk-UUG6ZSO5.js → chunk-DAYMXQG2.js} +28 -0
- package/payload/server/maxy-edge.js +1 -1
- package/payload/server/public/assets/{AdminLoginScreens-CH-kznsr.js → AdminLoginScreens-CMh1pdOp.js} +1 -1
- package/payload/server/public/assets/{AdminShell-DzgrZYpX.js → AdminShell-X9Bg3AKc.js} +1 -1
- package/payload/server/public/assets/{Checkbox-ChPx3-af.js → Checkbox-OCfZCTCO.js} +1 -1
- package/payload/server/public/assets/{admin-VHYR1DAp.js → admin-Bi2uYnAg.js} +1 -1
- package/payload/server/public/assets/{browser-DkTz3Atj.js → browser-B0ieZy-b.js} +1 -1
- package/payload/server/public/assets/{calendar-DlQUywFC.js → calendar-CRL2_9Fl.js} +1 -1
- package/payload/server/public/assets/chat-C56bmC6C.js +1 -0
- package/payload/server/public/assets/chevron-left-B2DKUxLk.js +1 -0
- package/payload/server/public/assets/data-C1ftMulP.js +1 -0
- package/payload/server/public/assets/{file-download-CX16TB7C.js → file-download-CHTyUQqS.js} +1 -1
- package/payload/server/public/assets/{graph-DPqvHCZ4.js → graph-CrJ-itbq.js} +1 -1
- package/payload/server/public/assets/{graph-labels-D_0uou0Z.js → graph-labels-CmCWaKJT.js} +1 -1
- package/payload/server/public/assets/{maximize-2-BTHbMSfz.js → maximize-2-_cJWIgx2.js} +1 -1
- package/payload/server/public/assets/{operator-BCZ7UoWU.js → operator-B4wN2c53.js} +1 -1
- package/payload/server/public/assets/page-BIfOKi9I.js +32 -0
- package/payload/server/public/assets/{page-CqN6MkYM.js → page-C1G57kPZ.js} +1 -1
- package/payload/server/public/assets/{public-DI5OCw9V.js → public-DmBKnBkr.js} +1 -1
- package/payload/server/public/assets/tasks-0OcRiBdQ.js +1 -0
- package/payload/server/public/assets/time-entry-format-D477WAes.js +1 -0
- package/payload/server/public/assets/{useCopyFeedback-8b1gtbRz.js → useCopyFeedback-CpfVOO26.js} +1 -1
- package/payload/server/public/assets/{useSubAccountSwitcher-DRRuGtGS.css → useSubAccountSwitcher-BKCWKo97.css} +1 -1
- package/payload/server/public/assets/useVoiceRecorder-CZPtVpM-.js +2 -0
- package/payload/server/public/browser.html +5 -5
- package/payload/server/public/calendar.html +6 -6
- package/payload/server/public/chat.html +11 -11
- package/payload/server/public/data.html +10 -9
- package/payload/server/public/graph.html +10 -10
- package/payload/server/public/index.html +13 -12
- package/payload/server/public/operator.html +13 -13
- package/payload/server/public/public.html +11 -11
- package/payload/server/public/tasks.html +4 -4
- package/payload/server/server.js +6 -5
- package/payload/server/public/assets/chat-CPOGOzmQ.js +0 -1
- package/payload/server/public/assets/chevron-left-Ch2-JhmS.js +0 -1
- package/payload/server/public/assets/data-Ppxc3gvy.js +0 -1
- package/payload/server/public/assets/page-BObPPwJr.js +0 -32
- package/payload/server/public/assets/play-Bw1WkeRC.js +0 -1
- package/payload/server/public/assets/tasks-Bj03-1M6.js +0 -1
- package/payload/server/public/assets/useVoiceRecorder-D9Qa7Eua.js +0 -2
- /package/payload/server/public/assets/{useSubAccountSwitcher-Cmbcd72K.js → useSubAccountSwitcher-Bo7bnFtC.js} +0 -0
package/package.json
CHANGED
|
@@ -5,6 +5,6 @@ export declare const EMBED_MS_PER_INPUT: number;
|
|
|
5
5
|
export declare const EMBED_REQUEST_TIMEOUT_MS: number;
|
|
6
6
|
export declare const EMBED_KEEP_ALIVE: number | string;
|
|
7
7
|
export declare function embed(text: string): Promise<number[]>;
|
|
8
|
-
export declare function
|
|
8
|
+
export declare function subBatchCharBudget(msPerChar: number): number;
|
|
9
9
|
export declare function embedBatch(texts: string[]): Promise<number[][]>;
|
|
10
10
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AA4BA,eAAO,MAAM,qBAAqB,QASjC,CAAC;AAaF,eAAO,MAAM,mBAAmB,QAA6D,CAAC;AAC9F,eAAO,MAAM,0BAA0B,QAGtC,CAAC;AAWF,eAAO,MAAM,kBAAkB,QAA8D,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AA4BA,eAAO,MAAM,qBAAqB,QASjC,CAAC;AAaF,eAAO,MAAM,mBAAmB,QAA6D,CAAC;AAC9F,eAAO,MAAM,0BAA0B,QAGtC,CAAC;AAWF,eAAO,MAAM,kBAAkB,QAA8D,CAAC;AAY9F,eAAO,MAAM,wBAAwB,QAGpC,CAAC;AAcF,eAAO,MAAM,gBAAgB,EAAE,MAAM,GAAG,MAEhB,CAAC;AA0FzB,wBAAsB,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAG3D;AAcD,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAM5D;AAED,wBAAsB,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAgDrE"}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
9
|
exports.EMBED_KEEP_ALIVE = exports.EMBED_REQUEST_TIMEOUT_MS = exports.EMBED_MS_PER_INPUT = exports.EMBED_SUBBATCH_COOLDOWN_MS = exports.EMBED_SUBBATCH_SIZE = exports.EMBED_INPUT_MAX_CHARS = void 0;
|
|
10
10
|
exports.embed = embed;
|
|
11
|
-
exports.
|
|
11
|
+
exports.subBatchCharBudget = subBatchCharBudget;
|
|
12
12
|
exports.embedBatch = embedBatch;
|
|
13
13
|
const OLLAMA_URL = process.env.OLLAMA_URL ?? "http://localhost:11434";
|
|
14
14
|
const EMBED_MODEL = process.env.EMBED_MODEL ?? "nomic-embed-text";
|
|
@@ -45,17 +45,21 @@ exports.EMBED_INPUT_MAX_CHARS = Math.max(1, Math.floor(envInt("EMBED_INPUT_MAX_C
|
|
|
45
45
|
// measurement without a code change.
|
|
46
46
|
exports.EMBED_SUBBATCH_SIZE = Math.max(1, Math.floor(envInt("EMBED_SUBBATCH_SIZE", 32)));
|
|
47
47
|
exports.EMBED_SUBBATCH_COOLDOWN_MS = Math.max(0, envInt("EMBED_SUBBATCH_COOLDOWN_MS", 1000));
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
// the
|
|
52
|
-
// so
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
// hardware.
|
|
48
|
+
// Drift baseline (Task 1553). This was Task 1533's sizing driver — the budgeted
|
|
49
|
+
// worst-case wall-time for one capped input — but sizing that from a single
|
|
50
|
+
// fixed reading is exactly what over-provisioned a POST on a device slower than
|
|
51
|
+
// the reading and aborted the ingest. Sizing is now measured per batch
|
|
52
|
+
// (nextSubBatchSize), so EMBED_MS_PER_INPUT no longer drives it. It survives as
|
|
53
|
+
// the reconciliation baseline: each POST compares its observed per-input time
|
|
54
|
+
// against this budget and warns (op=drift) when the device runs slower, so a
|
|
55
|
+
// device below budget is surfaced by a standing check rather than by a silent
|
|
56
|
+
// ingest failure. Env-tunable for other hardware.
|
|
57
57
|
exports.EMBED_MS_PER_INPUT = Math.max(1, Math.floor(envInt("EMBED_MS_PER_INPUT", 6000)));
|
|
58
58
|
const EMBED_TIMEOUT_UTILISATION = 0.8;
|
|
59
|
+
// Warn (op=drift) once a POST's observed per-input time exceeds the budget by
|
|
60
|
+
// this factor. 1.5 tolerates ordinary variance while catching a device that is
|
|
61
|
+
// materially slower than EMBED_MS_PER_INPUT.
|
|
62
|
+
const EMBED_DRIFT_WARN_RATIO = 1.5;
|
|
59
63
|
// Explicit per-request timeout, well under undici's 300000ms headers default.
|
|
60
64
|
// Without it a slow or wedged ollama returns no headers and the caller hangs a
|
|
61
65
|
// silent 5 minutes before a bare `fetch failed`. With it the request aborts in
|
|
@@ -130,7 +134,17 @@ async function embedRequest(input) {
|
|
|
130
134
|
// oversized or slow sub-batch is visible before it times out and the
|
|
131
135
|
// throughput-aware sizing is verifiable from the log. inputs is the POST's
|
|
132
136
|
// sub-batch size; maxChars its largest input.
|
|
133
|
-
|
|
137
|
+
const ms = Date.now() - startedAt;
|
|
138
|
+
process.stderr.write(`[embed] op=post inputs=${inputs.length} maxChars=${maxChars} ms=${ms}\n`);
|
|
139
|
+
// Standing reconciliation (Task 1553). Compare this POST's observed per-input
|
|
140
|
+
// time against the EMBED_MS_PER_INPUT budget and warn on drift, so a device
|
|
141
|
+
// slower than budget is caught by a check rather than by a later ingest that
|
|
142
|
+
// aborts. Sizing already adapts to the true rate; this line makes the
|
|
143
|
+
// slowness legible.
|
|
144
|
+
const observedMsPerInput = ms / inputs.length;
|
|
145
|
+
if (inputs.length > 0 && observedMsPerInput > exports.EMBED_MS_PER_INPUT * EMBED_DRIFT_WARN_RATIO) {
|
|
146
|
+
process.stderr.write(`[embed] op=drift observedMsPerInput=${Math.round(observedMsPerInput)} budgetMsPerInput=${exports.EMBED_MS_PER_INPUT} ratio=${(observedMsPerInput / exports.EMBED_MS_PER_INPUT).toFixed(1)}\n`);
|
|
147
|
+
}
|
|
134
148
|
return data.embeddings;
|
|
135
149
|
}
|
|
136
150
|
// POST one already-capped sub-batch of inputs to the embed endpoint.
|
|
@@ -141,30 +155,64 @@ async function embed(text) {
|
|
|
141
155
|
const data = await embedRequest(capInput(text));
|
|
142
156
|
return data[0];
|
|
143
157
|
}
|
|
144
|
-
// The
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
158
|
+
// The character budget for one POST, decided from the device's *measured*
|
|
159
|
+
// throughput rather than a fixed constant (Task 1553). Task 1533 sized every
|
|
160
|
+
// POST by input count against `EMBED_MS_PER_INPUT`, a single optimistic
|
|
161
|
+
// reading; on a device slower than that constant the derived POST was too large
|
|
162
|
+
// to finish inside `EMBED_REQUEST_TIMEOUT_MS`, so it aborted and the atomic
|
|
163
|
+
// ingest landed nothing. Sizing by *count* is also unsafe when inputs differ in
|
|
164
|
+
// size — an embed's wall-time scales with its characters, so a POST sized from
|
|
165
|
+
// a short probe (e.g. a document summary) over-provisions when the later inputs
|
|
166
|
+
// are near-cap sections. Bound each POST by total characters instead:
|
|
167
|
+
// `floor(EMBED_REQUEST_TIMEOUT_MS * 0.8 / msPerChar)`, floored at 1. A near-zero
|
|
168
|
+
// (very fast) rate yields an unbounded budget so the thermal count ceiling
|
|
169
|
+
// (`EMBED_SUBBATCH_SIZE`, Task 960) governs instead.
|
|
170
|
+
function subBatchCharBudget(msPerChar) {
|
|
171
|
+
const perChar = Math.max(msPerChar, Number.MIN_VALUE); // guard div-by-zero
|
|
172
|
+
return Math.max(1, Math.floor((exports.EMBED_REQUEST_TIMEOUT_MS * EMBED_TIMEOUT_UTILISATION) / perChar));
|
|
150
173
|
}
|
|
151
174
|
async function embedBatch(texts) {
|
|
152
175
|
const inputs = texts.map(capInput);
|
|
153
|
-
|
|
154
|
-
//
|
|
155
|
-
if (inputs.length <=
|
|
176
|
+
// 0 or 1 input is exactly one POST: the warm interactive query path is
|
|
177
|
+
// unaffected, and the empty-array behaviour is unchanged.
|
|
178
|
+
if (inputs.length <= 1) {
|
|
156
179
|
return postBatch(inputs);
|
|
157
180
|
}
|
|
158
|
-
//
|
|
159
|
-
//
|
|
181
|
+
// Measure-and-size. Probe with one input, then pack each subsequent POST with
|
|
182
|
+
// as many inputs as fit the character budget derived from the worst
|
|
183
|
+
// ms-per-char seen so far, capped by the thermal count ceiling. Using the
|
|
184
|
+
// running max makes the budget monotonic — a mid-batch slowdown (e.g. the
|
|
185
|
+
// ingest itself loading the CPU) shrinks the remainder and it never re-expands
|
|
186
|
+
// into a timeout. Cool between POSTs (not after the last) so the SoC gets gaps
|
|
187
|
+
// (Task 960). Vectors concatenate in input order so callers keep their
|
|
160
188
|
// index-aligned `embeddings[i]` ↔ `texts[i]` contract.
|
|
161
189
|
const out = [];
|
|
162
|
-
|
|
190
|
+
let maxMsPerChar = null;
|
|
191
|
+
for (let i = 0; i < inputs.length;) {
|
|
192
|
+
// Build the next chunk. With no measurement yet, probe with one input.
|
|
193
|
+
let end = i + 1;
|
|
194
|
+
if (maxMsPerChar !== null) {
|
|
195
|
+
const charBudget = subBatchCharBudget(maxMsPerChar);
|
|
196
|
+
let chars = inputs[i].length;
|
|
197
|
+
while (end < inputs.length &&
|
|
198
|
+
end - i < exports.EMBED_SUBBATCH_SIZE &&
|
|
199
|
+
chars + inputs[end].length <= charBudget) {
|
|
200
|
+
chars += inputs[end].length;
|
|
201
|
+
end += 1;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
163
204
|
if (i > 0 && exports.EMBED_SUBBATCH_COOLDOWN_MS > 0) {
|
|
164
205
|
await sleep(exports.EMBED_SUBBATCH_COOLDOWN_MS);
|
|
165
206
|
}
|
|
166
|
-
const chunk = inputs.slice(i,
|
|
167
|
-
|
|
207
|
+
const chunk = inputs.slice(i, end);
|
|
208
|
+
const chunkChars = chunk.reduce((s, t) => s + t.length, 0);
|
|
209
|
+
const startedAt = Date.now();
|
|
210
|
+
const vectors = await postBatch(chunk);
|
|
211
|
+
const observedMsPerChar = (Date.now() - startedAt) / Math.max(chunkChars, 1);
|
|
212
|
+
maxMsPerChar =
|
|
213
|
+
maxMsPerChar === null ? observedMsPerChar : Math.max(maxMsPerChar, observedMsPerChar);
|
|
214
|
+
out.push(...vectors);
|
|
215
|
+
i = end;
|
|
168
216
|
}
|
|
169
217
|
return out;
|
|
170
218
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAAA,yEAAyE;AACzE,6EAA6E;AAC7E,2EAA2E;AAC3E,6EAA6E;AAC7E,+EAA+E;AAC/E,gDAAgD;;;
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
|
@@ -54,17 +54,21 @@ export const EMBED_SUBBATCH_COOLDOWN_MS = Math.max(
|
|
|
54
54
|
envInt("EMBED_SUBBATCH_COOLDOWN_MS", 1000),
|
|
55
55
|
);
|
|
56
56
|
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
// the
|
|
61
|
-
// so
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
// hardware.
|
|
57
|
+
// Drift baseline (Task 1553). This was Task 1533's sizing driver — the budgeted
|
|
58
|
+
// worst-case wall-time for one capped input — but sizing that from a single
|
|
59
|
+
// fixed reading is exactly what over-provisioned a POST on a device slower than
|
|
60
|
+
// the reading and aborted the ingest. Sizing is now measured per batch
|
|
61
|
+
// (nextSubBatchSize), so EMBED_MS_PER_INPUT no longer drives it. It survives as
|
|
62
|
+
// the reconciliation baseline: each POST compares its observed per-input time
|
|
63
|
+
// against this budget and warns (op=drift) when the device runs slower, so a
|
|
64
|
+
// device below budget is surfaced by a standing check rather than by a silent
|
|
65
|
+
// ingest failure. Env-tunable for other hardware.
|
|
66
66
|
export const EMBED_MS_PER_INPUT = Math.max(1, Math.floor(envInt("EMBED_MS_PER_INPUT", 6000)));
|
|
67
67
|
const EMBED_TIMEOUT_UTILISATION = 0.8;
|
|
68
|
+
// Warn (op=drift) once a POST's observed per-input time exceeds the budget by
|
|
69
|
+
// this factor. 1.5 tolerates ordinary variance while catching a device that is
|
|
70
|
+
// materially slower than EMBED_MS_PER_INPUT.
|
|
71
|
+
const EMBED_DRIFT_WARN_RATIO = 1.5;
|
|
68
72
|
|
|
69
73
|
// Explicit per-request timeout, well under undici's 300000ms headers default.
|
|
70
74
|
// Without it a slow or wedged ollama returns no headers and the caller hangs a
|
|
@@ -157,9 +161,21 @@ async function embedRequest(input: string | string[]): Promise<number[][]> {
|
|
|
157
161
|
// oversized or slow sub-batch is visible before it times out and the
|
|
158
162
|
// throughput-aware sizing is verifiable from the log. inputs is the POST's
|
|
159
163
|
// sub-batch size; maxChars its largest input.
|
|
164
|
+
const ms = Date.now() - startedAt;
|
|
160
165
|
process.stderr.write(
|
|
161
|
-
`[embed] op=post inputs=${inputs.length} maxChars=${maxChars} ms=${
|
|
166
|
+
`[embed] op=post inputs=${inputs.length} maxChars=${maxChars} ms=${ms}\n`,
|
|
162
167
|
);
|
|
168
|
+
// Standing reconciliation (Task 1553). Compare this POST's observed per-input
|
|
169
|
+
// time against the EMBED_MS_PER_INPUT budget and warn on drift, so a device
|
|
170
|
+
// slower than budget is caught by a check rather than by a later ingest that
|
|
171
|
+
// aborts. Sizing already adapts to the true rate; this line makes the
|
|
172
|
+
// slowness legible.
|
|
173
|
+
const observedMsPerInput = ms / inputs.length;
|
|
174
|
+
if (inputs.length > 0 && observedMsPerInput > EMBED_MS_PER_INPUT * EMBED_DRIFT_WARN_RATIO) {
|
|
175
|
+
process.stderr.write(
|
|
176
|
+
`[embed] op=drift observedMsPerInput=${Math.round(observedMsPerInput)} budgetMsPerInput=${EMBED_MS_PER_INPUT} ratio=${(observedMsPerInput / EMBED_MS_PER_INPUT).toFixed(1)}\n`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
163
179
|
return data.embeddings;
|
|
164
180
|
}
|
|
165
181
|
|
|
@@ -173,36 +189,72 @@ export async function embed(text: string): Promise<number[]> {
|
|
|
173
189
|
return data[0];
|
|
174
190
|
}
|
|
175
191
|
|
|
176
|
-
// The
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
|
|
180
|
-
|
|
192
|
+
// The character budget for one POST, decided from the device's *measured*
|
|
193
|
+
// throughput rather than a fixed constant (Task 1553). Task 1533 sized every
|
|
194
|
+
// POST by input count against `EMBED_MS_PER_INPUT`, a single optimistic
|
|
195
|
+
// reading; on a device slower than that constant the derived POST was too large
|
|
196
|
+
// to finish inside `EMBED_REQUEST_TIMEOUT_MS`, so it aborted and the atomic
|
|
197
|
+
// ingest landed nothing. Sizing by *count* is also unsafe when inputs differ in
|
|
198
|
+
// size — an embed's wall-time scales with its characters, so a POST sized from
|
|
199
|
+
// a short probe (e.g. a document summary) over-provisions when the later inputs
|
|
200
|
+
// are near-cap sections. Bound each POST by total characters instead:
|
|
201
|
+
// `floor(EMBED_REQUEST_TIMEOUT_MS * 0.8 / msPerChar)`, floored at 1. A near-zero
|
|
202
|
+
// (very fast) rate yields an unbounded budget so the thermal count ceiling
|
|
203
|
+
// (`EMBED_SUBBATCH_SIZE`, Task 960) governs instead.
|
|
204
|
+
export function subBatchCharBudget(msPerChar: number): number {
|
|
205
|
+
const perChar = Math.max(msPerChar, Number.MIN_VALUE); // guard div-by-zero
|
|
206
|
+
return Math.max(
|
|
181
207
|
1,
|
|
182
|
-
Math.floor((EMBED_REQUEST_TIMEOUT_MS * EMBED_TIMEOUT_UTILISATION) /
|
|
208
|
+
Math.floor((EMBED_REQUEST_TIMEOUT_MS * EMBED_TIMEOUT_UTILISATION) / perChar),
|
|
183
209
|
);
|
|
184
|
-
return Math.min(EMBED_SUBBATCH_SIZE, durationCap);
|
|
185
210
|
}
|
|
186
211
|
|
|
187
212
|
export async function embedBatch(texts: string[]): Promise<number[][]> {
|
|
188
213
|
const inputs = texts.map(capInput);
|
|
189
|
-
const subBatch = effectiveSubBatchSize();
|
|
190
214
|
|
|
191
|
-
//
|
|
192
|
-
|
|
215
|
+
// 0 or 1 input is exactly one POST: the warm interactive query path is
|
|
216
|
+
// unaffected, and the empty-array behaviour is unchanged.
|
|
217
|
+
if (inputs.length <= 1) {
|
|
193
218
|
return postBatch(inputs);
|
|
194
219
|
}
|
|
195
220
|
|
|
196
|
-
//
|
|
197
|
-
//
|
|
221
|
+
// Measure-and-size. Probe with one input, then pack each subsequent POST with
|
|
222
|
+
// as many inputs as fit the character budget derived from the worst
|
|
223
|
+
// ms-per-char seen so far, capped by the thermal count ceiling. Using the
|
|
224
|
+
// running max makes the budget monotonic — a mid-batch slowdown (e.g. the
|
|
225
|
+
// ingest itself loading the CPU) shrinks the remainder and it never re-expands
|
|
226
|
+
// into a timeout. Cool between POSTs (not after the last) so the SoC gets gaps
|
|
227
|
+
// (Task 960). Vectors concatenate in input order so callers keep their
|
|
198
228
|
// index-aligned `embeddings[i]` ↔ `texts[i]` contract.
|
|
199
229
|
const out: number[][] = [];
|
|
200
|
-
|
|
230
|
+
let maxMsPerChar: number | null = null;
|
|
231
|
+
for (let i = 0; i < inputs.length; ) {
|
|
232
|
+
// Build the next chunk. With no measurement yet, probe with one input.
|
|
233
|
+
let end = i + 1;
|
|
234
|
+
if (maxMsPerChar !== null) {
|
|
235
|
+
const charBudget = subBatchCharBudget(maxMsPerChar);
|
|
236
|
+
let chars = inputs[i].length;
|
|
237
|
+
while (
|
|
238
|
+
end < inputs.length &&
|
|
239
|
+
end - i < EMBED_SUBBATCH_SIZE &&
|
|
240
|
+
chars + inputs[end].length <= charBudget
|
|
241
|
+
) {
|
|
242
|
+
chars += inputs[end].length;
|
|
243
|
+
end += 1;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
201
246
|
if (i > 0 && EMBED_SUBBATCH_COOLDOWN_MS > 0) {
|
|
202
247
|
await sleep(EMBED_SUBBATCH_COOLDOWN_MS);
|
|
203
248
|
}
|
|
204
|
-
const chunk = inputs.slice(i,
|
|
205
|
-
|
|
249
|
+
const chunk = inputs.slice(i, end);
|
|
250
|
+
const chunkChars = chunk.reduce((s, t) => s + t.length, 0);
|
|
251
|
+
const startedAt = Date.now();
|
|
252
|
+
const vectors = await postBatch(chunk);
|
|
253
|
+
const observedMsPerChar = (Date.now() - startedAt) / Math.max(chunkChars, 1);
|
|
254
|
+
maxMsPerChar =
|
|
255
|
+
maxMsPerChar === null ? observedMsPerChar : Math.max(maxMsPerChar, observedMsPerChar);
|
|
256
|
+
out.push(...vectors);
|
|
257
|
+
i = end;
|
|
206
258
|
}
|
|
207
259
|
return out;
|
|
208
260
|
}
|
|
@@ -174,6 +174,7 @@ Tools are available via the `admin` MCP server.
|
|
|
174
174
|
|
|
175
175
|
- `hooks/webfetch-preflight.mjs` — short-circuits WebFetch on JS-SPA shells with a structured `WEBFETCH_CANNOT_READ_JS_SPA` error so the agent surfaces a loud failure to the owner instead of paying the 60s extraction timeout. Fail-open on any internal error.
|
|
176
176
|
- `hooks/askuserquestion-investigate-gate.sh` — PreToolUse matcher=`AskUserQuestion`. Blocks the question (exit 2) when no read-only investigation tool has fired since the latest real user turn in the session JSONL. The structural fix for the failure class where the agent fabricates a menu before evidence-gathering (session `c085ec2c-46fb-4b73-8865-68cf85866ea8` 2026-05-22 — "change remote access password" → invented options "Admin PIN / Cloudflare tunnel / WiFi password" with zero prior tool_use; post-correction the agent immediately fired `remote-auth-status` → `ToolSearch` → `remote-auth-set-password`, proving it knew the moves). **Allowlist** (exact, with trailing `__<tool>` suffix-match for namespaced `mcp__plugin_<plugin>_<server>__<tool>` aliases): `ToolSearch`, `Grep`, `Glob`, `Read`, `LS`, `NotebookRead`, `Bash`, `WebFetch`, `WebSearch`, plus the read-only admin / memory MCP tools (`*-status`, `*-list`, `*-read`, `skill-find`, `memory-find-candidates`, `profile-read`, `conversation-list`, `memory-list-attachments`, `memory-read-attachment`). **Block message:** `Blocked: AskUserQuestion requires at least one investigation tool (ToolSearch, Grep, Read, *-list, *-status, *-read, skill-find, ...) earlier in this turn. Search the operator's literal phrase first.` **Log line** (stderr, one per call): `[ask-gate] decision=<allow|block> sessionId=<id8> seen=<csv|-> reason=<allowlist-hit|no-investigation|fail-open-no-transcript|fail-open-parse-error>`. **Fail-open** on missing transcript or parse error — nudges, never bricks the UI.
|
|
177
|
+
- `hooks/askuserquestion-channel-carrier-gate.sh` — PreToolUse matcher=`AskUserQuestion`, registered as a **second command on the same matcher** as the investigate-gate (either exit 2 blocks; the two guard different conditions). Blocks the question (exit 2) when the session has a native channel attached, because the Claude Code channel protocol carries only the two permission methods — there is no carrier for the `AskUserQuestion` elicitation, so on an rc-spawn channel (webchat/WhatsApp/Telegram) session the harness would block the turn awaiting a selection that can never arrive and the reader renders only delivered replies, wedging the turn (live SiteDesk incident session `1a085718` 2026-07-11: `/chat` stalled on the delivered "On it…" ack with no card). **Signal:** the rc-spawn writes the channel MCP registration as a config file in `os.tmpdir()` keyed by session id (`maxy-channel-<sid>.json` / `maxy-webchat-channel-<sid>.json` / `maxy-wa-channel-<sid>.json` / `maxy-telegram-channel-<sid>.json`, `sid` sanitized `[^A-Za-z0-9_-]→_` exactly as the writers do); its presence at tool-call time is the authoritative "channel attached to this session" marker. The hook resolves the tmpdir the way Node's `os.tmpdir()` does (`${TMPDIR:-/tmp}`, trailing slash stripped). Correct in both readings of the claude.ai/code scoping question: a Pi channel process sees the tmpfile (block); a claude.ai/code process on another host never sees the Pi tmpfile (fail-open → allow, that surface can answer the card). **Block message:** `Blocked: this is a native-channel session and AskUserQuestion cannot be delivered over a channel (the channel protocol carries no elicitation card, so the turn would wedge). Ask the same question as prose in your channel reply tool and wait for the operator's next message.` **Log line** (stderr, one per decisive call): `[ask-channel] decision=<allow|block> sessionId=<id8> channel=<attached|none> reason=<channel-attached|no-channel|fail-open-no-envelope|fail-open-no-session>`. **Fail-open** on no envelope, blank session id, or absent channel file — nudges, never bricks the UI. The complementary standing check (`interactive-wedge` in the session manager) catches any un-carried interactive tool_use that slips past this hook.
|
|
177
178
|
- `hooks/mcp-tool-missing.sh` — **PostToolUse hook on `mcp__.*` (directive 3).** Defence-in-depth for the `No such tool available: mcp__…` failure class that the name-binding is built to eliminate. Fires on any MCP tool call; no-op unless the `tool_response` carries `No such tool available` AND the qualified name resolves to a maxy plugin (read from the generated `hooks/lib/maxy-mcp-plugins.txt`). On a maxy match it logs one deterministic `[mcp-tool-missing] server=<server> tool=<tool>` line and exits 2 with a fixed envelope on stderr, so the agent relays a named server-unavailable failure instead of narrating "warming up" or blind-retrying. A missing non-maxy bridge tool (Playwright etc., upstream-owned) passes through (exit 0). The maxy-plugin list is regenerated and gate-diffed by `platform/scripts/check-canonical-tool-names.mjs`.
|
|
178
179
|
- `hooks/post-tool-use-agent.sh` — **PostToolUse hook on `Agent`.** Drains any subagent hook-decision buffers under `~/.maxy-code/logs/hook-decisions/` modified since this parent's previous PostToolUse-Agent fire (cursor file keyed by parent session id), prints one `[hook-propagate]` line per record to stdout — Claude Code attaches the stdout as a `hook_success` attachment on the parent JSONL, making the records grep-queryable from the parent session alone. Rotates consumed buffers to `consumed/`. Emits one `[hook-propagate-census] parentSession=<…> subagentHooksObserved=<N> attachmentsEmitted=<M>` line per fire to stdout and server.log; `N != M` is the propagation regression signal. The companion emitter library `hooks/lib/hook-emit.sh` is sourced by `post-tool-use-agent.sh` and any other hook that records a block decision (4 KB stderr truncation, `truncated=true` set on the record).
|
|
179
180
|
- `hooks/admin-authoring-observer.sh` — **PostToolUse hook on Write and Edit.** Observation only — never blocks; exits 0 on every path. Fires when the admin agent (not a specialist subagent — gated by `MAXY_SPECIALIST` env) writes or edits a file under `<accountDir>/output/`. Walks the session transcript from the latest real-user turn forward to detect any prior `Task` `tool_use` whose `subagent_type` starts with `specialists:`. Emits one stderr line `[admin-authoring] inline-write path=<rel> priorSpecialistSpawnInTurn=<true|false|unknown>`. A `false` value on a long-form prose file is the regression signal this hook was designed to make visible — the BioSymm proposal session (admin authored a customer-facing proposal inline despite content-producer being installed) is the failure mode this surfaces mechanically. Mechanical enforcement (refuse the write, force a re-spawn) is deferred per the task spec.
|
package/payload/platform/plugins/admin/hooks/__tests__/askuserquestion-channel-carrier-gate.test.sh
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Regression test for askuserquestion-channel-carrier-gate.sh.
|
|
3
|
+
#
|
|
4
|
+
# The hook blocks AskUserQuestion when the session has a native channel
|
|
5
|
+
# attached — signalled by a channel MCP config file for this session id in
|
|
6
|
+
# tmpdir. A channel carries no elicitation card, so the turn would wedge; the
|
|
7
|
+
# hook steers the agent to ask in prose via its channel reply tool instead.
|
|
8
|
+
#
|
|
9
|
+
# Covers (per .tasks/1552):
|
|
10
|
+
# 1. Channel attached (webchat config file present) + AskUserQuestion → BLOCK channel-attached
|
|
11
|
+
# 2. No channel file + AskUserQuestion → ALLOW no-channel
|
|
12
|
+
# 3. Non-AskUserQuestion tool → exit 0 silent (no [ask-channel] line)
|
|
13
|
+
# 4. PostToolUse event on AskUserQuestion → exit 0 silent
|
|
14
|
+
# 5. Missing session_id → ALLOW fail-open-no-session
|
|
15
|
+
# 6. No stdin envelope → ALLOW fail-open-no-envelope
|
|
16
|
+
# 7. Each of the 4 channel filename shapes present → BLOCK channel-attached
|
|
17
|
+
# 8. Session id needing sanitization, sanitized file present → BLOCK channel-attached
|
|
18
|
+
#
|
|
19
|
+
# All decisive cases assert exit code AND the decision-log signature on stderr.
|
|
20
|
+
|
|
21
|
+
set -u
|
|
22
|
+
|
|
23
|
+
HOOK="$(cd "$(dirname "$0")/.." && pwd)/askuserquestion-channel-carrier-gate.sh"
|
|
24
|
+
if [ ! -x "$HOOK" ]; then
|
|
25
|
+
echo "FAIL: $HOOK not executable" >&2
|
|
26
|
+
exit 1
|
|
27
|
+
fi
|
|
28
|
+
|
|
29
|
+
PASS=0
|
|
30
|
+
FAIL=0
|
|
31
|
+
|
|
32
|
+
# Run one case. Args:
|
|
33
|
+
# $1 = name
|
|
34
|
+
# $2 = stdin payload ("" for no-envelope: closed stdin)
|
|
35
|
+
# $3 = tmpdir the hook should scan (ASK_CHANNEL_TMPDIR override), "" for default
|
|
36
|
+
# $4 = expected exit code
|
|
37
|
+
# $5 = expected log signature regex (matched against stderr; "" = assert stderr is empty)
|
|
38
|
+
run_case() {
|
|
39
|
+
local name="$1" stdin="$2" scan_dir="$3" expected_exit="$4" expected_log_re="$5"
|
|
40
|
+
local stderr_file actual_exit
|
|
41
|
+
stderr_file="$(mktemp)"
|
|
42
|
+
if [ -z "$stdin" ]; then
|
|
43
|
+
# No envelope: close stdin so the hook's `[ -t 0 ]` / empty-read path fires.
|
|
44
|
+
ASK_CHANNEL_TMPDIR="$scan_dir" bash "$HOOK" >/dev/null 2>"$stderr_file" </dev/null
|
|
45
|
+
else
|
|
46
|
+
printf '%s' "$stdin" | ASK_CHANNEL_TMPDIR="$scan_dir" bash "$HOOK" >/dev/null 2>"$stderr_file"
|
|
47
|
+
fi
|
|
48
|
+
actual_exit=$?
|
|
49
|
+
local stderr_content
|
|
50
|
+
stderr_content=$(cat "$stderr_file")
|
|
51
|
+
rm -f "$stderr_file"
|
|
52
|
+
|
|
53
|
+
local exit_ok=0 log_ok=0
|
|
54
|
+
if [ "$actual_exit" -eq "$expected_exit" ]; then exit_ok=1; fi
|
|
55
|
+
if [ -z "$expected_log_re" ]; then
|
|
56
|
+
# Empty regex means: assert the hook emitted NO [ask-channel] line.
|
|
57
|
+
if ! printf '%s' "$stderr_content" | grep -q '\[ask-channel\]'; then log_ok=1; fi
|
|
58
|
+
else
|
|
59
|
+
if printf '%s' "$stderr_content" | grep -Eq "$expected_log_re"; then log_ok=1; fi
|
|
60
|
+
fi
|
|
61
|
+
|
|
62
|
+
if [ $exit_ok -eq 1 ] && [ $log_ok -eq 1 ]; then
|
|
63
|
+
echo "PASS: $name (exit=$actual_exit)"
|
|
64
|
+
PASS=$((PASS + 1))
|
|
65
|
+
else
|
|
66
|
+
echo "FAIL: $name" >&2
|
|
67
|
+
[ $exit_ok -eq 0 ] && echo " expected exit=$expected_exit, got=$actual_exit" >&2
|
|
68
|
+
[ $log_ok -eq 0 ] && echo " expected log regex: ${expected_log_re:-<none>}" >&2 && \
|
|
69
|
+
echo " actual stderr: $stderr_content" >&2
|
|
70
|
+
FAIL=$((FAIL + 1))
|
|
71
|
+
fi
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
SID="1a085718-1111-2222-3333-444455556666"
|
|
75
|
+
SID8="1a085718"
|
|
76
|
+
|
|
77
|
+
# Stdin payloads.
|
|
78
|
+
PRE_ASK="{\"hook_event_name\":\"PreToolUse\",\"tool_name\":\"AskUserQuestion\",\"session_id\":\"$SID\",\"tool_input\":{\"questions\":[]}}"
|
|
79
|
+
PRE_OTHER="{\"hook_event_name\":\"PreToolUse\",\"tool_name\":\"Bash\",\"session_id\":\"$SID\",\"tool_input\":{\"command\":\"ls\"}}"
|
|
80
|
+
POST_ASK="{\"hook_event_name\":\"PostToolUse\",\"tool_name\":\"AskUserQuestion\",\"session_id\":\"$SID\",\"tool_input\":{\"questions\":[]}}"
|
|
81
|
+
PRE_ASK_NO_SID='{"hook_event_name":"PreToolUse","tool_name":"AskUserQuestion","tool_input":{"questions":[]}}'
|
|
82
|
+
|
|
83
|
+
# ---------- Cases ----------
|
|
84
|
+
|
|
85
|
+
# 1. Channel attached (webchat config present) → BLOCK.
|
|
86
|
+
DIR_WEBCHAT=$(mktemp -d)
|
|
87
|
+
: > "$DIR_WEBCHAT/maxy-webchat-channel-$SID.json"
|
|
88
|
+
run_case "Webchat channel attached → BLOCK channel-attached" \
|
|
89
|
+
"$PRE_ASK" "$DIR_WEBCHAT" 2 \
|
|
90
|
+
"\[ask-channel\] decision=block sessionId=$SID8 channel=attached reason=channel-attached"
|
|
91
|
+
rm -rf "$DIR_WEBCHAT"
|
|
92
|
+
|
|
93
|
+
# 2. No channel file → ALLOW no-channel.
|
|
94
|
+
DIR_EMPTY=$(mktemp -d)
|
|
95
|
+
run_case "No channel file → ALLOW no-channel" \
|
|
96
|
+
"$PRE_ASK" "$DIR_EMPTY" 0 \
|
|
97
|
+
"\[ask-channel\] decision=allow sessionId=$SID8 channel=none reason=no-channel"
|
|
98
|
+
rm -rf "$DIR_EMPTY"
|
|
99
|
+
|
|
100
|
+
# 3. Non-AskUserQuestion tool → silent exit 0.
|
|
101
|
+
DIR_WITH_FILE=$(mktemp -d)
|
|
102
|
+
: > "$DIR_WITH_FILE/maxy-webchat-channel-$SID.json"
|
|
103
|
+
run_case "Non-AskUserQuestion tool → exit 0 silent" \
|
|
104
|
+
"$PRE_OTHER" "$DIR_WITH_FILE" 0 ""
|
|
105
|
+
rm -rf "$DIR_WITH_FILE"
|
|
106
|
+
|
|
107
|
+
# 4. PostToolUse event → silent exit 0 (even with channel file present).
|
|
108
|
+
DIR_POST=$(mktemp -d)
|
|
109
|
+
: > "$DIR_POST/maxy-webchat-channel-$SID.json"
|
|
110
|
+
run_case "PostToolUse on AskUserQuestion → exit 0 silent" \
|
|
111
|
+
"$POST_ASK" "$DIR_POST" 0 ""
|
|
112
|
+
rm -rf "$DIR_POST"
|
|
113
|
+
|
|
114
|
+
# 5. Missing session_id → ALLOW fail-open-no-session.
|
|
115
|
+
DIR_NOSID=$(mktemp -d)
|
|
116
|
+
run_case "Missing session_id → ALLOW fail-open-no-session" \
|
|
117
|
+
"$PRE_ASK_NO_SID" "$DIR_NOSID" 0 \
|
|
118
|
+
'\[ask-channel\] decision=allow sessionId=- channel=none reason=fail-open-no-session'
|
|
119
|
+
rm -rf "$DIR_NOSID"
|
|
120
|
+
|
|
121
|
+
# 6. No stdin envelope → ALLOW fail-open-no-envelope.
|
|
122
|
+
run_case "No stdin envelope → ALLOW fail-open-no-envelope" \
|
|
123
|
+
"" "" 0 \
|
|
124
|
+
'\[ask-channel\] decision=allow sessionId=- channel=none reason=fail-open-no-envelope'
|
|
125
|
+
|
|
126
|
+
# 7. Each of the 4 channel filename shapes present → BLOCK.
|
|
127
|
+
for kind in channel webchat-channel wa-channel telegram-channel; do
|
|
128
|
+
DIR_KIND=$(mktemp -d)
|
|
129
|
+
: > "$DIR_KIND/maxy-$kind-$SID.json"
|
|
130
|
+
run_case "Channel file shape maxy-$kind-<sid>.json → BLOCK channel-attached" \
|
|
131
|
+
"$PRE_ASK" "$DIR_KIND" 2 \
|
|
132
|
+
"\[ask-channel\] decision=block sessionId=$SID8 channel=attached reason=channel-attached"
|
|
133
|
+
rm -rf "$DIR_KIND"
|
|
134
|
+
done
|
|
135
|
+
|
|
136
|
+
# 8. Session id needing sanitization: raw id has chars outside [A-Za-z0-9_-];
|
|
137
|
+
# the writer sanitizes to '_', so the on-disk file uses the sanitized id.
|
|
138
|
+
RAW_SID="acc/one:sess" # '/' and ':' are sanitized to '_'
|
|
139
|
+
SAN_SID="acc_one_sess"
|
|
140
|
+
DIR_SAN=$(mktemp -d)
|
|
141
|
+
: > "$DIR_SAN/maxy-webchat-channel-$SAN_SID.json"
|
|
142
|
+
PRE_ASK_RAW="{\"hook_event_name\":\"PreToolUse\",\"tool_name\":\"AskUserQuestion\",\"session_id\":\"$RAW_SID\",\"tool_input\":{\"questions\":[]}}"
|
|
143
|
+
run_case "Sanitized session id resolves the file → BLOCK channel-attached" \
|
|
144
|
+
"$PRE_ASK_RAW" "$DIR_SAN" 2 \
|
|
145
|
+
'\[ask-channel\] decision=block sessionId=acc/one: channel=attached reason=channel-attached'
|
|
146
|
+
rm -rf "$DIR_SAN"
|
|
147
|
+
|
|
148
|
+
# 9. Real tmpdir resolution fallback: no ASK_CHANNEL_TMPDIR override, TMPDIR
|
|
149
|
+
# unset, TMP set → the hook must resolve TMP (mirroring Node's os.tmpdir())
|
|
150
|
+
# and find the config file there. Proves the production resolution order, not
|
|
151
|
+
# just the test override.
|
|
152
|
+
DIR_TMP=$(mktemp -d)
|
|
153
|
+
: > "$DIR_TMP/maxy-webchat-channel-$SID.json"
|
|
154
|
+
tmp_stderr="$(mktemp)"
|
|
155
|
+
printf '%s' "$PRE_ASK" | env -u TMPDIR -u ASK_CHANNEL_TMPDIR -u TEMP TMP="$DIR_TMP" \
|
|
156
|
+
bash "$HOOK" >/dev/null 2>"$tmp_stderr"
|
|
157
|
+
tmp_exit=$?
|
|
158
|
+
if [ "$tmp_exit" -eq 2 ] && grep -Eq "\[ask-channel\] decision=block sessionId=$SID8 channel=attached reason=channel-attached" "$tmp_stderr"; then
|
|
159
|
+
echo "PASS: TMP-fallback resolution finds the config file → BLOCK (exit=$tmp_exit)"
|
|
160
|
+
PASS=$((PASS + 1))
|
|
161
|
+
else
|
|
162
|
+
echo "FAIL: TMP-fallback resolution" >&2
|
|
163
|
+
echo " expected exit=2 got=$tmp_exit; stderr: $(cat "$tmp_stderr")" >&2
|
|
164
|
+
FAIL=$((FAIL + 1))
|
|
165
|
+
fi
|
|
166
|
+
rm -f "$tmp_stderr"
|
|
167
|
+
rm -rf "$DIR_TMP"
|
|
168
|
+
|
|
169
|
+
echo
|
|
170
|
+
echo "──────── askuserquestion-channel-carrier-gate test summary ────────"
|
|
171
|
+
echo "PASS: $PASS"
|
|
172
|
+
echo "FAIL: $FAIL"
|
|
173
|
+
|
|
174
|
+
[ "$FAIL" -gt 0 ] && exit 1
|
|
175
|
+
exit 0
|