@code-yeongyu/senpi-codemode 2026.7.25-2
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/CHANGELOG.md +250 -0
- package/LICENSE +22 -0
- package/README.md +161 -0
- package/package.json +58 -0
- package/src/bridge/http-server.ts +236 -0
- package/src/bridge/protocol.ts +198 -0
- package/src/bridge/reserved.ts +9 -0
- package/src/bridges/agent-bridge.ts +197 -0
- package/src/bridges/output-bridge.ts +96 -0
- package/src/bridges/schema-injection.ts +3 -0
- package/src/codemode/runtime.ts +258 -0
- package/src/codemode/tools.ts +106 -0
- package/src/completion/handler.ts +192 -0
- package/src/completion/tool-bridge.ts +55 -0
- package/src/config/settings.ts +215 -0
- package/src/extension/runtime-factory.ts +114 -0
- package/src/extension/session-manager-proxy.ts +116 -0
- package/src/extension/session-manager.ts +215 -0
- package/src/host-sdk.ts +1 -0
- package/src/index.ts +181 -0
- package/src/interpreters/detect.ts +161 -0
- package/src/kernels/jl/kernel.ts +37 -0
- package/src/kernels/jl/prelude.jl +283 -0
- package/src/kernels/jl/runner.jl +327 -0
- package/src/kernels/js/context-manager.ts +296 -0
- package/src/kernels/js/inline-worker-entry.js +23 -0
- package/src/kernels/js/inline-worker.ts +15 -0
- package/src/kernels/js/kernel-contract.ts +38 -0
- package/src/kernels/js/local-module-loader.ts +108 -0
- package/src/kernels/js/prelude.ts +15 -0
- package/src/kernels/js/rewrite-imports.ts +164 -0
- package/src/kernels/js/run-queue.ts +82 -0
- package/src/kernels/js/worker-core.d.ts +18 -0
- package/src/kernels/js/worker-core.js +94 -0
- package/src/kernels/js/worker-entry.js +23 -0
- package/src/kernels/js/worker-host.ts +117 -0
- package/src/kernels/js/worker-indirect-eval.js +88 -0
- package/src/kernels/js/worker-runtime.js +401 -0
- package/src/kernels/py/kernel-contract.ts +32 -0
- package/src/kernels/py/kernel.ts +290 -0
- package/src/kernels/py/prelude.py +954 -0
- package/src/kernels/py/process.ts +119 -0
- package/src/kernels/py/transport.ts +237 -0
- package/src/kernels/rb/kernel.ts +26 -0
- package/src/kernels/rb/prelude.rb +270 -0
- package/src/kernels/rb/runner.rb +204 -0
- package/src/kernels/shared/subprocess-contract.ts +22 -0
- package/src/kernels/shared/subprocess-kernel.ts +266 -0
- package/src/kernels/shared/subprocess-process.ts +174 -0
- package/src/kernels/shared/subprocess-queue.ts +101 -0
- package/src/kernels/shared/subprocess-run.ts +98 -0
- package/src/output/output-meta.ts +89 -0
- package/src/output/streaming-output.ts +296 -0
- package/src/prompt/eval-prompt.ts +319 -0
- package/src/timeouts/bridge-timeout.ts +16 -0
- package/src/timeouts/idle-timeout.ts +84 -0
- package/src/tool/cell-handler.ts +279 -0
- package/src/tool/eval-tool.ts +285 -0
- package/src/tool/image.ts +274 -0
- package/src/tool/json-tree.ts +247 -0
- package/src/tool/render.ts +876 -0
- package/src/tool/status-events.ts +12 -0
- package/src/tool/types.ts +114 -0
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
export interface EnabledLanguages {
|
|
2
|
+
readonly py: boolean;
|
|
3
|
+
readonly js: boolean;
|
|
4
|
+
readonly rb: boolean;
|
|
5
|
+
readonly jl: boolean;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface EvalPromptParts {
|
|
9
|
+
readonly description: string;
|
|
10
|
+
readonly promptSnippet: string;
|
|
11
|
+
readonly promptGuidelines: readonly string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface EvalPromptOptions {
|
|
15
|
+
readonly spawns: boolean;
|
|
16
|
+
readonly spawnDefaultAgent?: string;
|
|
17
|
+
/** Active model id; selects the emphasis dialect of the batching guidance. */
|
|
18
|
+
readonly modelId?: string;
|
|
19
|
+
/** Preformatted host line (e.g. "darwin arm64 · Apple M5 Max · 18 cores"); enables the host-sizing note. */
|
|
20
|
+
readonly hostLine?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Prompt dialect for the eval-first batching emphasis. */
|
|
24
|
+
export type EvalEmphasisStyle = "default" | "claude" | "codex" | "kimi";
|
|
25
|
+
|
|
26
|
+
const CLAUDE_MODEL_RE = /(^|[/.:])claude[-.]/i;
|
|
27
|
+
const GLM_MODEL_RE = /(^|[/.:@-])glm[-.]?\d/i;
|
|
28
|
+
const KIMI_MODEL_RE = /(^|[/.:])kimi[-.]/i;
|
|
29
|
+
const OPENAI_MODEL_RE = /(^|[/.:])(gpt|chatgpt|codex)[-.]|(^|[/.:])o[134](?:[-.]|$)/i;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Selects the eval-first batching dialect for a model id:
|
|
33
|
+
* - `claude`: Claude/GLM — direct imperatives; both are steered most reliably
|
|
34
|
+
* by explicit tagged directives (GLM prompting guidance routes to Claude's).
|
|
35
|
+
* - `codex`: OpenAI reasoning families — terse bounded rules, no emphasis spam.
|
|
36
|
+
* - `kimi`: Kimi K-series — maximum-emphasis POSITIVE imperatives (uppercase/
|
|
37
|
+
* bold DO-framing); all-caps NEVER prohibitions stay out because they make
|
|
38
|
+
* K-series overthink instead of comply.
|
|
39
|
+
* - `default`: everything else (and no model) — maximum-emphasis fallback.
|
|
40
|
+
*/
|
|
41
|
+
export function evalEmphasisStyle(modelId: string | undefined): EvalEmphasisStyle {
|
|
42
|
+
if (!modelId) return "default";
|
|
43
|
+
if (CLAUDE_MODEL_RE.test(modelId) || GLM_MODEL_RE.test(modelId)) return "claude";
|
|
44
|
+
if (KIMI_MODEL_RE.test(modelId)) return "kimi";
|
|
45
|
+
if (OPENAI_MODEL_RE.test(modelId)) return "codex";
|
|
46
|
+
return "default";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type ContextValue = string | boolean;
|
|
50
|
+
type Context = Readonly<Record<string, ContextValue>>;
|
|
51
|
+
type EvalPromptExample = {
|
|
52
|
+
readonly caption: string;
|
|
53
|
+
readonly language: keyof EnabledLanguages;
|
|
54
|
+
readonly title: string;
|
|
55
|
+
readonly code: string;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// senpi ToolDefinition has no examples field, so description embeds the examples.
|
|
59
|
+
// ADAPTATION: payloads diverge from omp's json-config chain to teach batch read,
|
|
60
|
+
// comprehension filtering, and parallel tool.<name> fan-out while keeping the
|
|
61
|
+
// three-cell reuse narrative.
|
|
62
|
+
const REUSE_CHAIN_EXAMPLES = [
|
|
63
|
+
{
|
|
64
|
+
caption: "First call — set up once",
|
|
65
|
+
language: "py",
|
|
66
|
+
title: "collect targets",
|
|
67
|
+
code: "from pathlib import Path\nfrom collections import Counter\nfiles = [p for p in Path('src').rglob('*.ts') if 'test' not in p.parts]\nprint(len(files))",
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
caption: "Second call — reuse `files`, batch-read in one cell",
|
|
71
|
+
language: "py",
|
|
72
|
+
title: "scan usages",
|
|
73
|
+
code: "hits = Counter()\nfor p in files:\n hits[p.name] = read(p).count('legacyClient')\ndisplay({k: v for k, v in hits.items() if v})",
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
caption: "Third call — reuse results, fan out session tools in parallel",
|
|
77
|
+
language: "py",
|
|
78
|
+
title: "confirm callsites",
|
|
79
|
+
code: "dirs = ['src/core', 'src/tools']\ndisplay(parallel([lambda d=d: tool.grep({'pattern': 'legacyClient', 'path': d}) for d in dirs]))",
|
|
80
|
+
},
|
|
81
|
+
] as const satisfies readonly EvalPromptExample[];
|
|
82
|
+
|
|
83
|
+
const EVAL_PROMPT_TEMPLATE = `Run one step of code in a persistent kernel.
|
|
84
|
+
|
|
85
|
+
<instruction>
|
|
86
|
+
**One eval call = one cell = one logical step.** State persists per language across separate eval calls and tool calls{{#if spawns}}, and \`task\` subagents{{/if}} — define helpers, datasets, and clients in one call, then later calls reuse them directly.
|
|
87
|
+
|
|
88
|
+
Work incrementally: imports in one call, define in the next, test, then use — each its own eval call. Re-run setup ONLY after \`reset\`, a kernel crash, or a \`NameError\`/\`ReferenceError\` proving the state is gone.
|
|
89
|
+
|
|
90
|
+
{{#if styleClaude}}<eval_first_batching>
|
|
91
|
+
\`eval\` is your default execution surface: if a step needs more than one tool call, write ONE cell that performs the whole step — never issue the calls one at a time.
|
|
92
|
+
- Enumerate every lookup the step needs, then run all independent ones simultaneously with \`parallel(thunks)\` inside the cell; keep calls sequential only when one result feeds the next.
|
|
93
|
+
- Write real code around the calls: loop or comprehend over file sets with \`read()\`/stdlib, branch per case, and wrap risky calls in try/except so one failure degrades only its item — recover or retry inside the cell, keep the batch alive.
|
|
94
|
+
- Post-process \`tool.<name>()\` results programmatically and return distilled facts, not raw dumps.
|
|
95
|
+
</eval_first_batching>{{/if}}{{#if styleCodex}}Route multi-call steps through eval: one cell per step, independent lookups dispatched together via \`parallel(thunks)\`; keep work sequential only when one result determines the next action.
|
|
96
|
+
- Loop or comprehend over file sets with \`read()\`/stdlib instead of reading files one call at a time; post-process \`tool.<name>()\` results programmatically.
|
|
97
|
+
- Wrap failable calls in try/except inside the cell; a failed item degrades only itself. After two distinct failed strategies for the same fact, fall back to direct tool calls.
|
|
98
|
+
- Reduce large results in-kernel to the facts the task needs before returning.{{/if}}{{#if styleKimi}}**EVAL IS YOUR SUPERPOWER — MAKE IT YOUR DEFAULT WAY TO ACT.** Before any step, think: "how do I execute this WHOLE step in ONE parallelized cell?" — then write that ONE cell.
|
|
99
|
+
- **BATCH EVERYTHING AT ONCE:** enumerate EVERY independent lookup the step needs and dispatch them ALL simultaneously with \`parallel(thunks)\` in that cell; keep calls sequential only when one result feeds the next.
|
|
100
|
+
- **WRITE REAL CODE, NOT CALL CHAINS:** loop or comprehend over file sets with \`read()\`/stdlib, post-process \`tool.<name>()\` results programmatically, and put try/except around each risky call so the rest of the batch completes.
|
|
101
|
+
- **DISTILL IN-KERNEL:** filter and aggregate results in code, then return ONLY the distilled facts.{{/if}}{{#if styleDefault}}**EVAL IS YOUR PRIMARY EXECUTION SURFACE.** Any step that needs MORE THAN ONE tool call MUST be written as ONE cell — NEVER as a chain of single tool calls.
|
|
102
|
+
- **PLAN THE WHOLE STEP, THEN BATCH IT.** Enumerate every read/search/lookup the step needs and dispatch ALL independent ones through \`parallel(thunks)\` in one cell.
|
|
103
|
+
- **WRITE REAL CODE, NOT CALL LISTS.** Loop or comprehend over file sets with \`read()\`/stdlib, branch \`if\`/\`else\` per case, post-process \`tool.<name>()\` results programmatically, and wrap EVERY risky call in try/except so ONE failure NEVER kills the batch.
|
|
104
|
+
- **DISTILL IN-KERNEL.** Filter, diff, and aggregate in code before returning; return facts, NOT dumps.{{/if}}
|
|
105
|
+
{{#if hostLine}}
|
|
106
|
+
Host: {{hostLine}} — cells execute here. Size \`parallel(thunks)\` pools to its cores; \`tool.<name>()\` shell commands must fit this platform, even when the code you are writing targets another machine.
|
|
107
|
+
{{/if}}
|
|
108
|
+
|
|
109
|
+
Fields:
|
|
110
|
+
|
|
111
|
+
- \`language\` — {{#if py}}\`"py"\` IPython kernel{{/if}}{{#ifAll py js}}, {{/ifAll}}{{#if js}}\`"js"\` persistent JavaScript VM{{/if}}{{#if rb}}{{#ifAny py js}}, {{/ifAny}}\`"rb"\` persistent Ruby kernel{{/if}}{{#if jl}}{{#ifAny py js rb}}, {{/ifAny}}\`"jl"\` persistent Julia kernel{{/if}}.
|
|
112
|
+
- \`code\` — cell body, verbatim. Newlines/quotes JSON-encoded; no fences, no headers.
|
|
113
|
+
- \`title\` (optional) — short transcript label (e.g. \`"imports"\`).
|
|
114
|
+
- \`timeout\` (optional) — seconds. Raise only for heavy compute or long{{#if spawns}} non-agent{{/if}} tool calls.
|
|
115
|
+
- \`reset\` (optional) — wipe this language's kernel first.{{#ifAll py js}} Per-language: a \`py\` reset never touches the JS VM.{{/ifAll}}
|
|
116
|
+
|
|
117
|
+
{{#if py}}Live event loop: use top-level \`await\` directly; \`asyncio.run(…)\` raises "cannot be called from a running event loop".{{/if}}
|
|
118
|
+
{{#if js}}JS runs under Node.js worker: top-level \`await\`/\`return\` work; \`fetch\`/\`Buffer\` available.{{/if}}
|
|
119
|
+
{{#if rb}}Ruby: synchronous; helper options are keyword args{{#if spawns}} (e.g. \`output("id", limit: 2)\`){{/if}}; the last expression auto-displays unless it is \`nil\`, an assignment, or a definition (like IRB).{{/if}}
|
|
120
|
+
{{#if jl}}Julia: synchronous; helper options are standard keyword args{{#if spawns}} (e.g. \`output("id", limit=2)\`){{/if}}; the last expression auto-displays unless it is an assignment or a definition (like the Julia REPL).{{/if}}
|
|
121
|
+
On error, fix and re-run only the failing step — prior calls' state survives.
|
|
122
|
+
</instruction>
|
|
123
|
+
|
|
124
|
+
<prelude>
|
|
125
|
+
{{#ifAll py js}}Same helpers + arg order, both runtimes. Python: sync, options = trailing kwargs. JS: async/\`await\`able, options = ONE trailing object literal, never positional (extras throw).{{else}}{{#if py}}Sync; options = trailing kwargs.{{/if}}{{#if js}}Async/\`await\`able; options = ONE trailing object literal, never positional (extras throw).{{/if}}{{/ifAll}}{{#if rb}} Ruby: sync, options = trailing keyword args.{{/if}}{{#if jl}} Julia: sync, options = trailing keyword args.{{/if}}
|
|
126
|
+
\`\`\`
|
|
127
|
+
display(value) → None
|
|
128
|
+
Cell output; figures/images/dataframes shown natively.
|
|
129
|
+
print(value, ...) → None
|
|
130
|
+
Text output.
|
|
131
|
+
read(path, offset?=1, limit?=None) → str
|
|
132
|
+
File as text; offset/limit are 1-indexed lines. Accepts \`local://…\`.
|
|
133
|
+
write(path, content) → str
|
|
134
|
+
Write file (creates parents) → resolved path. \`local://…\` persists across turns/subagents.
|
|
135
|
+
env(key?=None, value?=None) → str | None | dict
|
|
136
|
+
No args → full env dict; one → value of \`key\`; two → set \`key=value\`, return value.
|
|
137
|
+
{{#if spawns}}output(*ids, format?="raw", offset?=None, limit?=None) → str | dict | list[dict]
|
|
138
|
+
Task/agent output by id. \`format\` selects full (\`"raw"\`) or trailing (\`"tail"\`) output.
|
|
139
|
+
{{/if}}tool.<name>(args) → unknown
|
|
140
|
+
Invoke any session tool; \`args\` = its parameter object.
|
|
141
|
+
completion(prompt, model?="default", system?=None, schema?=None) → str | dict
|
|
142
|
+
Oneshot, stateless (no history/tools). \`model\`: \`"smol"\` fast | \`"default"\` session | \`"slow"\` most capable. \`schema\` (JSON-Schema) → structured output, parsed object.
|
|
143
|
+
{{#if spawns}}agent(prompt, agent?="{{spawnDefaultAgent}}", model?=None, label?=None, schema?=None, handle?=False) → str | dict
|
|
144
|
+
Run a subagent → final output. \`agent\` picks another discovered agent; omit it to use \`{{spawnDefaultAgent}}\`. \`schema\` as in completion(). Background via \`local://\` files named in the prompt. \`handle\` → DAG node dict { text, output, handle: \`agent://<id>\`, id, agent } (parsed under \`data\` when \`schema\` set).
|
|
145
|
+
{{#if js}} JS: options are ONE trailing object — agent(prompt, { agent, schema, handle }).
|
|
146
|
+
{{/if}}{{/if}}parallel(thunks) → list
|
|
147
|
+
Thunks through a bounded pool (wide as a \`task\` batch — don't pre-shrink), input order kept; returns when all finish, a throwing thunk propagates.
|
|
148
|
+
pipeline(items, ...stages) → list
|
|
149
|
+
Map items through one-arg stages left-to-right, barrier between stages; stage 1 gets the item, later stages the previous result.
|
|
150
|
+
log(message) → None
|
|
151
|
+
Progress line above the status tree.
|
|
152
|
+
phase(title) → None
|
|
153
|
+
Phase grouping subsequent status lines.
|
|
154
|
+
\`\`\`
|
|
155
|
+
</prelude>
|
|
156
|
+
{{#if spawns}}
|
|
157
|
+
<dag>
|
|
158
|
+
Pipe handles through stage helpers to build a dependency graph — acyclic waves:
|
|
159
|
+
- **Name nodes.** Capture each \`agent(…, {{#if py}}handle=True{{/if}}{{#if js}}{ handle: true }{{/if}}{{#if jl}}handle=true{{/if}})\` result; carries \`handle\` (\`agent://<id>\`) + \`output\`.
|
|
160
|
+
- **Wire edges by reference.** Put an upstream node's \`handle\`/\`output\` in the dependent stage's prompt — large transcript never re-inlined. Bulk: \`write("local://<name>.md", …)\`, pass the URI.
|
|
161
|
+
- **\`pipeline(items, *stages)\` = staged waves**, barrier between stages (every item clears stage N before any enters N+1). **\`parallel(thunks)\` = one wave** of independent nodes.
|
|
162
|
+
- **Isolate failure.** A raising node re-raises the lowest-index error, aborts its wave; wrap risky nodes in try/except so a failure degrades only its dependent subtree, independent branches finish.
|
|
163
|
+
- **Acyclic only.** A node never waits on its own descendant.
|
|
164
|
+
</dag>
|
|
165
|
+
{{/if}}
|
|
166
|
+
|
|
167
|
+
<critical>
|
|
168
|
+
Prior top-level names (\`data\`, \`sessions\`, helpers, imports) survive into the next eval call — reuse them; NEVER re-import, re-require, or re-declare a helper. Re-read a file only if it may have changed since the last read.
|
|
169
|
+
</critical>`;
|
|
170
|
+
|
|
171
|
+
export function buildEvalPrompt(
|
|
172
|
+
enabled: EnabledLanguages,
|
|
173
|
+
options: EvalPromptOptions = { spawns: false },
|
|
174
|
+
): EvalPromptParts {
|
|
175
|
+
if (!enabled.py && !enabled.js && !enabled.rb && !enabled.jl) {
|
|
176
|
+
throw new Error("no kernels enabled for eval prompt");
|
|
177
|
+
}
|
|
178
|
+
const spawnDefaultAgent = options.spawnDefaultAgent ?? "task";
|
|
179
|
+
const style = evalEmphasisStyle(options.modelId);
|
|
180
|
+
const context: Context = {
|
|
181
|
+
py: enabled.py,
|
|
182
|
+
js: enabled.js,
|
|
183
|
+
rb: enabled.rb,
|
|
184
|
+
jl: enabled.jl,
|
|
185
|
+
spawns: options.spawns,
|
|
186
|
+
spawnDefaultAgent,
|
|
187
|
+
styleClaude: style === "claude",
|
|
188
|
+
styleCodex: style === "codex",
|
|
189
|
+
styleKimi: style === "kimi",
|
|
190
|
+
styleDefault: style === "default",
|
|
191
|
+
hostLine: options.hostLine ?? "",
|
|
192
|
+
};
|
|
193
|
+
const examples = REUSE_CHAIN_EXAMPLES.filter((example) => enabled[example.language])
|
|
194
|
+
.map((example) => {
|
|
195
|
+
const call = { language: example.language, title: example.title, code: example.code };
|
|
196
|
+
return `### ${example.caption}\n\`\`\`json\n${JSON.stringify(call, null, 2)}\n\`\`\``;
|
|
197
|
+
})
|
|
198
|
+
.join("\n\n");
|
|
199
|
+
const description = [
|
|
200
|
+
renderTemplate(EVAL_PROMPT_TEMPLATE, context)
|
|
201
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
202
|
+
.trim(),
|
|
203
|
+
examples === "" ? "" : `<examples>\n${examples}\n</examples>`,
|
|
204
|
+
]
|
|
205
|
+
.filter((part) => part !== "")
|
|
206
|
+
.join("\n\n");
|
|
207
|
+
return {
|
|
208
|
+
description,
|
|
209
|
+
promptSnippet: "Run one incremental code cell in a persistent language kernel.",
|
|
210
|
+
promptGuidelines: [
|
|
211
|
+
BATCHING_GUIDELINES[style],
|
|
212
|
+
"Use eval reset only when a language kernel must be wiped; reset is scoped to the selected language.",
|
|
213
|
+
],
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* System-prompt guideline per emphasis dialect. The default dialect carries
|
|
219
|
+
* maximum emphasis so unmapped models still batch through eval; the others are
|
|
220
|
+
* tuned to what steers that family reliably.
|
|
221
|
+
*/
|
|
222
|
+
const BATCHING_GUIDELINES: Record<EvalEmphasisStyle, string> = {
|
|
223
|
+
default:
|
|
224
|
+
"**EVAL FIRST.** Any step needing MORE THAN ONE tool call MUST be ONE eval cell: run independent calls in parallel, wrap risky calls in try/except, and return distilled facts — NEVER a chain of single tool calls.",
|
|
225
|
+
claude:
|
|
226
|
+
"Prefer eval for any step needing more than one tool call: one cell that runs independent calls in parallel, handles per-call failures in code, and returns distilled facts.",
|
|
227
|
+
codex: "Route multi-call steps through eval: one cell per step, independent calls dispatched in parallel; fall back to direct tool calls when one call is sufficient or each result changes the next decision.",
|
|
228
|
+
kimi: "**EVAL IS YOUR SUPERPOWER — DEFAULT TO IT.** Execute EVERY multi-call step as ONE eval cell: run ALL independent calls simultaneously via parallel(thunks), handle failures per item in code, and return ONLY distilled facts.",
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
function renderTemplate(template: string, context: Context): string {
|
|
232
|
+
let index = 0;
|
|
233
|
+
const [rendered, nextIndex] = renderUntil(template, context, index, []);
|
|
234
|
+
index = nextIndex;
|
|
235
|
+
if (index !== template.length) {
|
|
236
|
+
throw new Error("unexpected template close tag");
|
|
237
|
+
}
|
|
238
|
+
return rendered;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function renderUntil(
|
|
242
|
+
template: string,
|
|
243
|
+
context: Context,
|
|
244
|
+
start: number,
|
|
245
|
+
stopTags: readonly string[],
|
|
246
|
+
): readonly [string, number, string?] {
|
|
247
|
+
let rendered = "";
|
|
248
|
+
let index = start;
|
|
249
|
+
while (index < template.length) {
|
|
250
|
+
const open = template.indexOf("{{", index);
|
|
251
|
+
if (open < 0) {
|
|
252
|
+
return [rendered + template.slice(index), template.length];
|
|
253
|
+
}
|
|
254
|
+
rendered += template.slice(index, open);
|
|
255
|
+
const close = template.indexOf("}}", open + 2);
|
|
256
|
+
if (close < 0) {
|
|
257
|
+
throw new Error("unterminated template tag");
|
|
258
|
+
}
|
|
259
|
+
const tag = template.slice(open + 2, close).trim();
|
|
260
|
+
index = close + 2;
|
|
261
|
+
if (stopTags.includes(tag)) {
|
|
262
|
+
return [rendered, index, tag];
|
|
263
|
+
}
|
|
264
|
+
if (tag.startsWith("#")) {
|
|
265
|
+
const [block, nextIndex] = renderBlock(template, context, index, tag);
|
|
266
|
+
rendered += block;
|
|
267
|
+
index = nextIndex;
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
if (tag.startsWith("/")) {
|
|
271
|
+
throw new Error(`unexpected template close tag ${tag}`);
|
|
272
|
+
}
|
|
273
|
+
rendered += valueFor(tag, context);
|
|
274
|
+
}
|
|
275
|
+
return [rendered, index];
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function renderBlock(template: string, context: Context, start: number, openTag: string): readonly [string, number] {
|
|
279
|
+
const [kind, ...names] = openTag.slice(1).split(/\s+/);
|
|
280
|
+
const closeTag = `/${kind}`;
|
|
281
|
+
const [truthyText, afterTruthy, stopTag] = renderUntil(template, context, start, ["else", closeTag]);
|
|
282
|
+
let falseyText = "";
|
|
283
|
+
let end = afterTruthy;
|
|
284
|
+
if (stopTag === "else") {
|
|
285
|
+
const [elseText, afterElse, elseStop] = renderUntil(template, context, afterTruthy, [closeTag]);
|
|
286
|
+
if (elseStop !== closeTag) {
|
|
287
|
+
throw new Error(`missing close tag for ${kind}`);
|
|
288
|
+
}
|
|
289
|
+
falseyText = elseText;
|
|
290
|
+
end = afterElse;
|
|
291
|
+
} else if (stopTag !== closeTag) {
|
|
292
|
+
throw new Error(`missing close tag for ${kind}`);
|
|
293
|
+
}
|
|
294
|
+
return [condition(kind, names, context) ? truthyText : falseyText, end];
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function condition(kind: string, names: readonly string[], context: Context): boolean {
|
|
298
|
+
if (kind === "if") {
|
|
299
|
+
return names.length === 1 && Boolean(context[names[0]]);
|
|
300
|
+
}
|
|
301
|
+
if (kind === "ifAll") {
|
|
302
|
+
return names.length > 0 && names.every((name) => Boolean(context[name]));
|
|
303
|
+
}
|
|
304
|
+
if (kind === "ifAny") {
|
|
305
|
+
return names.length > 0 && names.some((name) => Boolean(context[name]));
|
|
306
|
+
}
|
|
307
|
+
throw new Error(`unknown template condition ${kind}`);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function valueFor(name: string, context: Context): string {
|
|
311
|
+
const value = context[name];
|
|
312
|
+
if (typeof value === "string") {
|
|
313
|
+
return value;
|
|
314
|
+
}
|
|
315
|
+
if (typeof value === "boolean" || value === undefined) {
|
|
316
|
+
return "";
|
|
317
|
+
}
|
|
318
|
+
return String(value);
|
|
319
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export { TIMEOUT_PAUSE_OP, TIMEOUT_RESUME_OP } from "../bridge/reserved.ts";
|
|
2
|
+
|
|
3
|
+
import type { TimeoutPauseHandle } from "./idle-timeout.ts";
|
|
4
|
+
|
|
5
|
+
export async function withBridgeTimeoutPause<T>(
|
|
6
|
+
watchdog: TimeoutPauseHandle | undefined,
|
|
7
|
+
operation: () => Promise<T>,
|
|
8
|
+
): Promise<T> {
|
|
9
|
+
if (watchdog === undefined) return operation();
|
|
10
|
+
watchdog.pause();
|
|
11
|
+
try {
|
|
12
|
+
return await operation();
|
|
13
|
+
} finally {
|
|
14
|
+
watchdog.resume();
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
export interface IdleTimeoutEvent {
|
|
2
|
+
readonly cellId: string;
|
|
3
|
+
readonly error: Error;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface IdleTimeoutOptions {
|
|
7
|
+
readonly cellId: string;
|
|
8
|
+
readonly timeoutMs: number;
|
|
9
|
+
readonly onTimeout: (event: IdleTimeoutEvent) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface TimeoutPauseHandle {
|
|
13
|
+
pause(): void;
|
|
14
|
+
resume(): void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class IdleTimeout implements TimeoutPauseHandle {
|
|
18
|
+
readonly #cellId: string;
|
|
19
|
+
readonly #onTimeout: (event: IdleTimeoutEvent) => void;
|
|
20
|
+
readonly #controller = new AbortController();
|
|
21
|
+
readonly signal = this.#controller.signal;
|
|
22
|
+
readonly timeoutMs: number;
|
|
23
|
+
#deadlineMs: number;
|
|
24
|
+
#timer: ReturnType<typeof setTimeout> | undefined;
|
|
25
|
+
#pauseDepth = 0;
|
|
26
|
+
#settled = false;
|
|
27
|
+
|
|
28
|
+
constructor(options: IdleTimeoutOptions) {
|
|
29
|
+
this.#cellId = options.cellId;
|
|
30
|
+
this.timeoutMs = Math.max(1, Math.floor(options.timeoutMs));
|
|
31
|
+
this.#deadlineMs = Date.now() + this.timeoutMs;
|
|
32
|
+
this.#onTimeout = options.onTimeout;
|
|
33
|
+
this.#arm(this.timeoutMs);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
pause(): void {
|
|
37
|
+
if (this.#settled) return;
|
|
38
|
+
this.#pauseDepth++;
|
|
39
|
+
if (this.#pauseDepth !== 1) return;
|
|
40
|
+
this.#clearTimer();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
resume(): void {
|
|
44
|
+
if (this.#settled || this.#pauseDepth === 0) return;
|
|
45
|
+
this.#pauseDepth--;
|
|
46
|
+
if (this.#pauseDepth > 0) return;
|
|
47
|
+
this.#deadlineMs = Date.now() + this.timeoutMs;
|
|
48
|
+
this.#arm(this.timeoutMs);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
dispose(): void {
|
|
52
|
+
if (this.#settled) return;
|
|
53
|
+
this.#settled = true;
|
|
54
|
+
this.#clearTimer();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
#arm(delayMs: number): void {
|
|
58
|
+
this.#clearTimer();
|
|
59
|
+
const timer = setTimeout(() => this.#expire(), Math.max(0, delayMs));
|
|
60
|
+
timer.unref?.();
|
|
61
|
+
this.#timer = timer;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
#clearTimer(): void {
|
|
65
|
+
if (this.#timer === undefined) return;
|
|
66
|
+
clearTimeout(this.#timer);
|
|
67
|
+
this.#timer = undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
#expire(): void {
|
|
71
|
+
if (this.#settled || this.#pauseDepth > 0) return;
|
|
72
|
+
const remainingMs = this.#deadlineMs - Date.now();
|
|
73
|
+
if (remainingMs > 0) {
|
|
74
|
+
this.#arm(remainingMs);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
this.#settled = true;
|
|
78
|
+
this.#timer = undefined;
|
|
79
|
+
const error = new Error(`Cell timed out after ${this.timeoutMs}ms`);
|
|
80
|
+
error.name = "TimeoutError";
|
|
81
|
+
this.#controller.abort(error);
|
|
82
|
+
this.#onTimeout({ cellId: this.#cellId, error });
|
|
83
|
+
}
|
|
84
|
+
}
|