@llblab/pi-telegram 0.11.0 → 0.11.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/AGENTS.md +4 -2
- package/CHANGELOG.md +22 -2
- package/README.md +38 -10
- package/docs/architecture.md +137 -30
- package/docs/command-templates.md +81 -24
- package/index.ts +33 -42
- package/lib/command-templates.ts +163 -32
- package/lib/config.ts +64 -0
- package/lib/lifecycle.ts +86 -0
- package/lib/menu-settings.ts +139 -21
- package/lib/pi.ts +4 -0
- package/lib/prompts.ts +1 -0
- package/lib/routing.ts +3 -10
- package/lib/time-injection.ts +78 -0
- package/lib/turns.ts +13 -0
- package/package.json +1 -1
|
@@ -4,12 +4,14 @@ Command templates are the portable integration format for deterministic local au
|
|
|
4
4
|
|
|
5
5
|
**Meta-contract:** transportable (bit-for-bit identical across projects), high-density (zero fluff), constant (evolve by crystallizing, not speculating), optimal minimum (add only when it hurts).
|
|
6
6
|
|
|
7
|
-
**Scope:** portable synchronous command execution format — shell-free exec, composition/pipes, timeout
|
|
7
|
+
**Scope:** portable synchronous command execution format — shell-free exec, composition/pipes, optional timeout, delay-before-start, bounded retry, failure propagation, recover cleanup, output artifact selection, and handler-level fallback. Single JSON standard; no platform lock-in.
|
|
8
8
|
|
|
9
9
|
---
|
|
10
10
|
|
|
11
11
|
Extensions may choose their own config files, selectors, placeholder sources, and examples, but should preserve this core contract.
|
|
12
12
|
|
|
13
|
+
Layer boundary: command templates own only the synchronous execution graph. Recipe imports, import-reference expressions, recipe lookup, `async: true`, run ids, state dirs, FIFO controls, and outbox events are host/recipe/async-run configuration layers, not portable command-template syntax.
|
|
14
|
+
|
|
13
15
|
## Shape
|
|
14
16
|
|
|
15
17
|
A command template is either a command-line string or an ordered array of command-template leaves:
|
|
@@ -32,13 +34,15 @@ Common object fields:
|
|
|
32
34
|
|
|
33
35
|
- `label`: Optional human label for diagnostics and parallel branch reports.
|
|
34
36
|
- `mode`: Optional execution mode for array templates. Default is `"sequence"`; `"parallel"` runs children concurrently.
|
|
35
|
-
- `args`: Optional placeholder
|
|
37
|
+
- `args`: Optional placeholder declarations. Untyped names remain valid; compact typed forms such as `file:path`, `timeout:int`, `speed:number`, `dry_run:bool`, `prompts:array`, and `mode:enum(check,fix)` are valid when the host supports typed tool schemas. Defaults belong in `defaults` or inline placeholder defaults; hosts may normalize interactive shorthand such as `timeout:int=60000` before persistence.
|
|
36
38
|
- `defaults`: Placeholder default values by name.
|
|
37
|
-
- `timeout`: Optional execution timeout in milliseconds.
|
|
39
|
+
- `timeout`: Optional execution timeout in milliseconds. Omit it, or set `0`, to leave the command unbounded. Set an explicit positive timeout when a tool must fail closed instead of waiting indefinitely.
|
|
38
40
|
- `delay`: Optional wait in milliseconds before starting this node. Default is no delay.
|
|
39
41
|
- `output`: Optional result selector. Default is `"stdout"`; runtime values such as `"ogg"` are valid.
|
|
40
42
|
- `retry`: Optional max attempts including the first. Default is `1`.
|
|
41
|
-
- `critical`: Optional boolean.
|
|
43
|
+
- `critical`: Optional boolean. Backward-compatible alias for `failure: "root"`.
|
|
44
|
+
- `failure`: Optional failure propagation scope: `continue`, `branch`, or `root`. Default is `continue`.
|
|
45
|
+
- `recover`: Optional command template run between failed retry attempts. Recovery output is ignored; recovery failure stops retries.
|
|
42
46
|
- `template`: Required command string or ordered composition array.
|
|
43
47
|
|
|
44
48
|
For object form, write `template` last. Read the node flags first, then the executable content. Storage paths, labels, selectors, descriptions, and registry-specific metadata belong to each extension's local schema.
|
|
@@ -63,8 +67,9 @@ Supported forms:
|
|
|
63
67
|
| ---------------- | ------------------------------------------------ |
|
|
64
68
|
| `{name}` | Required value from runtime values or `defaults` |
|
|
65
69
|
| `{name=default}` | Inline default when no value is provided |
|
|
70
|
+
| `{items[index]}` | Array item selected by literal or repeat index |
|
|
66
71
|
|
|
67
|
-
Resolution order is runtime values → `defaults` → inline default → error.
|
|
72
|
+
Resolution order is runtime values → `defaults` → inline default → error. Default values that are themselves a single placeholder, such as `{prompt}` resolving to `{prompts[index]}`, are resolved recursively with a small depth guard. A repeat node may set `repeat` to `{items.length}` when an array arg should determine fanout width.
|
|
68
73
|
|
|
69
74
|
```json
|
|
70
75
|
{
|
|
@@ -80,6 +85,8 @@ With runtime values `{ "text": "hello" }`, argv is:
|
|
|
80
85
|
|
|
81
86
|
Use `defaults` for visible configuration data; use inline defaults for compact local literals. Prefer flag-style examples such as `/path/to/tool --file {file} --lang {lang=ru}` for readability, but positional forms such as `/path/to/tool {file} {lang=ru}` are valid when the invoked script defines that CLI contract.
|
|
82
87
|
|
|
88
|
+
Typed declarations annotate the public tool interface, not the shell command. They may live in `args` or inline placeholders such as `{timeout:int=60000}` and `{mode:enum(check,fix)=check}`. Use metadata-first authoring (`args` plus `defaults`) when long templates should stay visually short; use inline-first authoring when one self-contained `template` property is clearer. They do not sandbox or reinterpret the executable; they only let the host generate narrower input schemas and normalize runtime values before placeholder substitution. Untyped `args` and untyped placeholders continue to work unchanged.
|
|
89
|
+
|
|
83
90
|
## Quoting
|
|
84
91
|
|
|
85
92
|
Placeholder values are not shell-escaped because no shell is used. A value containing spaces remains one argv item when it replaces one split word:
|
|
@@ -122,12 +129,12 @@ Composition rules:
|
|
|
122
129
|
|
|
123
130
|
- Execute leaves in order when `mode` is omitted or set to `"sequence"`
|
|
124
131
|
- Execute child templates concurrently when `mode` is set to `"parallel"`
|
|
125
|
-
- Parallel composition uses soft-quorum semantics by default: failed
|
|
126
|
-
- Non-critical failures are recorded and execution continues, while `
|
|
132
|
+
- Parallel composition uses soft-quorum semantics by default: failed children are reported as degraded branches unless failure propagation escalates
|
|
133
|
+
- Non-critical failures are recorded and execution continues, while `failure: "branch"` stops the current branch and `failure: "root"` aborts the root composition
|
|
127
134
|
- Treat the whole composition as one handler for selector matching and fallback
|
|
128
135
|
- Top-level `args` and `defaults` apply to every leaf unless the leaf defines private values
|
|
129
136
|
- Leaf `args` replace inherited `args`; leaf `defaults` merge over inherited defaults; `timeout` and `output` are not inherited into leaves
|
|
130
|
-
-
|
|
137
|
+
- Timeout is disabled by default; configure a positive `timeout` for bounded commands that should fail closed
|
|
131
138
|
- Each sequence leaf receives the previous leaf's stdout on stdin by default, while the final leaf stdout remains the default composition result
|
|
132
139
|
- Each parallel child receives the same stdin, and child stdout values are joined in stable array order before flowing to the next sequence leaf
|
|
133
140
|
- Parallel branch joins include branch label and status, and tool details include branch metadata plus coverage summary
|
|
@@ -231,38 +238,76 @@ Legacy local schemas may accept `pipe` as an alias, but the portable standard is
|
|
|
231
238
|
|
|
232
239
|
By default, composition continues on failure: the failed step is logged and the next step executes. This is analogous to `make -k` — the user sees all failures at once and decides what to fix.
|
|
233
240
|
|
|
234
|
-
##
|
|
241
|
+
## Failure Propagation
|
|
242
|
+
|
|
243
|
+
By default, failed steps use `failure: "continue"`: record the failure, clear stdout for that step, and continue the current sequence. This preserves the fail-open profile.
|
|
244
|
+
|
|
245
|
+
Use `failure` when a node should stop more aggressively:
|
|
235
246
|
|
|
236
|
-
|
|
247
|
+
- `"continue"`: record the failure and continue the current sequence.
|
|
248
|
+
- `"branch"`: stop the current sequence/subtree and return a failed branch to the nearest parent. In a parallel node, sibling branches keep running and the join becomes degraded. At the root, branch failure is still a tool failure.
|
|
249
|
+
- `"root"`: abort the outermost composition.
|
|
237
250
|
|
|
238
251
|
```json
|
|
239
252
|
{
|
|
253
|
+
"mode": "parallel",
|
|
240
254
|
"template": [
|
|
241
|
-
{
|
|
242
|
-
|
|
243
|
-
|
|
255
|
+
{
|
|
256
|
+
"label": "agent-a",
|
|
257
|
+
"failure": "branch",
|
|
258
|
+
"template": [
|
|
259
|
+
"agent-a-work {scope}",
|
|
260
|
+
"agent-a-validate {scope}",
|
|
261
|
+
"agent-a-push {scope}"
|
|
262
|
+
]
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
"label": "agent-b",
|
|
266
|
+
"failure": "branch",
|
|
267
|
+
"template": [
|
|
268
|
+
"agent-b-work {scope}",
|
|
269
|
+
"agent-b-validate {scope}",
|
|
270
|
+
"agent-b-push {scope}"
|
|
271
|
+
]
|
|
272
|
+
}
|
|
244
273
|
]
|
|
245
274
|
}
|
|
246
275
|
```
|
|
247
276
|
|
|
248
|
-
`
|
|
277
|
+
If `agent-a-validate` fails, `agent-a-push` is skipped, `agent-b` can still finish, and the parallel join reports degraded branch coverage.
|
|
249
278
|
|
|
250
|
-
|
|
279
|
+
`critical: true` remains a backward-compatible alias for `failure: "root"`. Prefer `failure` for new templates because it names the propagation scope directly.
|
|
251
280
|
|
|
252
281
|
## Retry
|
|
253
282
|
|
|
254
|
-
Set `retry: N`
|
|
283
|
+
Set `retry: N` to attempt execution up to `N` times including the first. The first successful attempt stops the retry loop.
|
|
284
|
+
|
|
285
|
+
On leaf commands, retry repeats that command. On sequence or parallel nodes, retry repeats the whole node. A retried group only retries when the group returns a failure, so validator checkpoints normally pair group retry with `failure: "branch"` or `failure: "root"`.
|
|
255
286
|
|
|
256
287
|
```json
|
|
257
288
|
{
|
|
258
|
-
"
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
289
|
+
"failure": "branch",
|
|
290
|
+
"retry": 3,
|
|
291
|
+
"template": ["implement {scope}", "npm test", "git diff --check"]
|
|
292
|
+
}
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
Here the whole group runs again when a validator fails. Without `failure: "branch"`, the failed validator would be logged and the group would continue by default.
|
|
296
|
+
|
|
297
|
+
## Recover
|
|
298
|
+
|
|
299
|
+
Set `recover` on a retried node to run cleanup after a failed attempt and before the next attempt. `recover` is another command template: it can be a string command, sequence, or mode tree. Its output is ignored and the next retry receives the original stdin.
|
|
300
|
+
|
|
301
|
+
```json
|
|
302
|
+
{
|
|
303
|
+
"failure": "branch",
|
|
304
|
+
"retry": 3,
|
|
305
|
+
"recover": "git -C {work_dir} reset --hard HEAD",
|
|
306
|
+
"template": ["pi -p --tools read,edit,bash {scope_file}", "npm test"]
|
|
262
307
|
}
|
|
263
308
|
```
|
|
264
309
|
|
|
265
|
-
`
|
|
310
|
+
`recover` is not a fallback success path. It is cleanup between attempts. Practical uses include resetting a worktree, removing temp files, clearing generated output, releasing a local lock, or stopping a helper process before trying the node again. If recovery fails, retries stop and the recovery failure is returned. Recovery uses fail-closed semantics by default; set an explicit `failure` inside a recover template only when a softer cleanup failure is intentional.
|
|
266
311
|
|
|
267
312
|
## Delay
|
|
268
313
|
|
|
@@ -290,12 +335,24 @@ string → leaf command
|
|
|
290
335
|
string[] → sequential composition
|
|
291
336
|
{ template } → leaf command object
|
|
292
337
|
{ mode, template } → sequence or parallel subtree
|
|
293
|
-
{ mode, args, defaults, delay, retry,
|
|
338
|
+
{ mode, args, defaults, delay, retry, failure, recover, output, template } → full node
|
|
294
339
|
```
|
|
295
340
|
|
|
296
|
-
Start with a string. Add composition when needed. Add `mode: "parallel"` when independent work can run concurrently. Add delay when launch pacing matters. Add retry when flaky. Add
|
|
341
|
+
Start with a string. Add composition when needed. Add `mode: "parallel"` when independent work can run concurrently. Add delay when launch pacing matters. Add retry when flaky. Add `failure` when propagation scope matters. Add `recover` when a retried node needs cleanup before another attempt. Same contract, growing capability, no dead weight.
|
|
342
|
+
|
|
343
|
+
`mode: "parallel"` is the synchronous fanout shape. Saved JSON recipes and detached lifecycle concerns such as logs, cancellation, and durable state belong to host-specific recipe/async-run standards, not to command templates.
|
|
344
|
+
|
|
345
|
+
## Trust Boundary
|
|
346
|
+
|
|
347
|
+
Command templates avoid shell interpolation by splitting the template into argv first and substituting placeholders per arg. A placeholder value containing spaces remains one argv value, not a shell fragment.
|
|
348
|
+
|
|
349
|
+
This is not a sandbox. The executable still runs with the same user permissions as the host agent. Shells, interpreter eval modes, destructive filesystem commands, and local scripts remain trusted code. Examples that deserve extra operator attention:
|
|
350
|
+
|
|
351
|
+
- `bash`, `sh`, `zsh`, or `fish`, especially with `-c`.
|
|
352
|
+
- `node -e`, `python -c`, `ruby -e`, `perl -e`, or similar eval modes.
|
|
353
|
+
- `rm`, `mv`, `cp`, or `rsync` over broad paths or placeholder-derived paths.
|
|
297
354
|
|
|
298
|
-
|
|
355
|
+
Hosts may surface lightweight warnings for these obvious high-risk shapes. Warnings should inform review without blocking existing tools, because many trusted local wrappers intentionally use shells or filesystem mutation.
|
|
299
356
|
|
|
300
357
|
## Tool Boundary
|
|
301
358
|
|
package/index.ts
CHANGED
|
@@ -11,7 +11,6 @@ import * as Config from "./lib/config.ts";
|
|
|
11
11
|
import {
|
|
12
12
|
createTelegramExtensionSectionRegistry,
|
|
13
13
|
setGlobalTelegramSectionRegistry,
|
|
14
|
-
registerTelegramSection,
|
|
15
14
|
type TelegramSectionRegistry,
|
|
16
15
|
} from "./lib/extension-sections.ts";
|
|
17
16
|
import { createTelegramExternalHandleUpdate } from "./lib/external-handlers.ts";
|
|
@@ -38,6 +37,7 @@ import * as Runtime from "./lib/runtime.ts";
|
|
|
38
37
|
import * as Setup from "./lib/setup.ts";
|
|
39
38
|
import * as Status from "./lib/status.ts";
|
|
40
39
|
import * as TextGroups from "./lib/text-groups.ts";
|
|
40
|
+
import * as TimeInjection from "./lib/time-injection.ts";
|
|
41
41
|
import * as Voice from "./lib/voice.ts";
|
|
42
42
|
|
|
43
43
|
const VOICE_EVENT_RECORDER_KEY = "__piTelegramVoiceEventRecorder__";
|
|
@@ -45,42 +45,6 @@ const VOICE_EVENT_RECORDER_KEY = "__piTelegramVoiceEventRecorder__";
|
|
|
45
45
|
type ActivePiModel = NonNullable<Pi.ExtensionContext["model"]>;
|
|
46
46
|
type RuntimeTelegramQueueItem = Queue.TelegramQueueItem<Pi.ExtensionContext>;
|
|
47
47
|
|
|
48
|
-
export {
|
|
49
|
-
registerTelegramOutboundHandler,
|
|
50
|
-
hasTelegramOutboundHandler,
|
|
51
|
-
getTelegramOutboundProgrammaticHandlers,
|
|
52
|
-
recordTelegramRuntimeEvent,
|
|
53
|
-
} from "./lib/outbound-handlers.ts";
|
|
54
|
-
|
|
55
|
-
// --- Voice Integration Exports ---
|
|
56
|
-
// Prefer domain imports from ./lib/voice.ts; root exports stay for compatibility.
|
|
57
|
-
export {
|
|
58
|
-
registerTelegramVoiceSynthesisProvider,
|
|
59
|
-
getTelegramVoiceSynthesisProviders,
|
|
60
|
-
hasTelegramVoiceSynthesisProvider,
|
|
61
|
-
clearTelegramVoiceSynthesisProviders,
|
|
62
|
-
planTelegramVoiceReply,
|
|
63
|
-
getTelegramVoiceReplyMode,
|
|
64
|
-
computeVoiceTurnFlags,
|
|
65
|
-
isVoiceTurn,
|
|
66
|
-
shouldSuppressPreviewForVoice,
|
|
67
|
-
computeVoicePromptContribution,
|
|
68
|
-
type TelegramVoiceSynthesisProvider,
|
|
69
|
-
type TelegramVoiceTurnView,
|
|
70
|
-
type TelegramVoiceSynthesisProviderResult,
|
|
71
|
-
type TelegramVoiceReplyMode,
|
|
72
|
-
} from "./lib/voice.ts";
|
|
73
|
-
|
|
74
|
-
// --- Extension Section Exports ---
|
|
75
|
-
export {
|
|
76
|
-
registerTelegramSection,
|
|
77
|
-
type TelegramSectionRegistration,
|
|
78
|
-
type TelegramSectionContext,
|
|
79
|
-
type TelegramSectionCallbackContext,
|
|
80
|
-
type TelegramSectionView,
|
|
81
|
-
type TelegramSectionSettingsRegistration,
|
|
82
|
-
} from "./lib/extension-sections.ts";
|
|
83
|
-
|
|
84
48
|
// --- Extension Runtime ---
|
|
85
49
|
|
|
86
50
|
export default function (pi: Pi.ExtensionAPI) {
|
|
@@ -98,7 +62,10 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
98
62
|
Config.setGlobalTelegramConfigRuntime({
|
|
99
63
|
updateVoiceConfig(voice) {
|
|
100
64
|
const current = configStore.get();
|
|
101
|
-
const next = {
|
|
65
|
+
const next = {
|
|
66
|
+
...current,
|
|
67
|
+
voice: { ...(current.voice ?? {}), ...voice },
|
|
68
|
+
};
|
|
102
69
|
configStore.set(next);
|
|
103
70
|
void configStore.persist(next);
|
|
104
71
|
},
|
|
@@ -113,6 +80,10 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
113
80
|
Config.createTelegramVoiceReplyModeConfiguredChecker(configStore);
|
|
114
81
|
const setVoiceReplyMode =
|
|
115
82
|
Config.createTelegramVoiceReplyModeSetter(configStore);
|
|
83
|
+
const getTimeInjectionMode =
|
|
84
|
+
Config.createTelegramTimeInjectionModeGetter(configStore);
|
|
85
|
+
const setTimeInjectionMode =
|
|
86
|
+
Config.createTelegramTimeInjectionModeSetter(configStore);
|
|
116
87
|
const lockRuntime = Locks.createTelegramLockRuntime<Pi.ExtensionContext>();
|
|
117
88
|
const lockOwnershipGuard =
|
|
118
89
|
Locks.createTelegramLockOwnershipGuard(lockRuntime);
|
|
@@ -132,14 +103,16 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
132
103
|
createTelegramExtensionSectionRegistry();
|
|
133
104
|
setGlobalTelegramSectionRegistry(sectionRegistry);
|
|
134
105
|
|
|
135
|
-
|
|
136
106
|
const runtimeEvents = Status.createTelegramRuntimeEventRecorder({
|
|
137
107
|
getBotToken: configStore.getBotToken,
|
|
138
108
|
});
|
|
139
109
|
const recordRuntimeEvent = runtimeEvents.record;
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
110
|
+
const timeInjectionRuntime = TimeInjection.createTimeInjectionRuntime({
|
|
111
|
+
getConfig: Config.createTelegramTimeConfigGetter(configStore),
|
|
112
|
+
recordRuntimeEvent,
|
|
113
|
+
});
|
|
114
|
+
(globalThis as Record<string, unknown>)[VOICE_EVENT_RECORDER_KEY] =
|
|
115
|
+
recordRuntimeEvent;
|
|
143
116
|
const getContextModel = Pi.getExtensionContextModel;
|
|
144
117
|
const isIdle = Pi.isExtensionContextIdle;
|
|
145
118
|
const hasPendingMessages = Pi.hasExtensionContextPendingMessages;
|
|
@@ -156,6 +129,7 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
156
129
|
Queue.createTelegramQueueStore<Pi.ExtensionContext>();
|
|
157
130
|
const deferredQueueDispatchRuntime =
|
|
158
131
|
Queue.createTelegramDeferredQueueDispatchRuntime<Pi.ExtensionContext>({
|
|
132
|
+
delayMs: 50,
|
|
159
133
|
recordRuntimeEvent,
|
|
160
134
|
});
|
|
161
135
|
const pollingControllerState = Polling.createTelegramPollingControllerState();
|
|
@@ -386,8 +360,10 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
386
360
|
isProactivePushEnabled,
|
|
387
361
|
getVoiceReplyMode,
|
|
388
362
|
isVoiceReplyModeConfigured,
|
|
363
|
+
getTimeInjectionMode,
|
|
389
364
|
setProactivePushEnabled,
|
|
390
365
|
setVoiceReplyMode,
|
|
366
|
+
setTimeInjectionMode,
|
|
391
367
|
},
|
|
392
368
|
sectionRegistry,
|
|
393
369
|
);
|
|
@@ -435,6 +411,7 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
435
411
|
setMyCommands,
|
|
436
412
|
getCommands,
|
|
437
413
|
downloadFile: downloadTelegramBridgeFile,
|
|
414
|
+
resolveTimeLine: timeInjectionRuntime.resolveLine,
|
|
438
415
|
getThinkingLevel,
|
|
439
416
|
setThinkingLevel,
|
|
440
417
|
persistScopedModelPatterns: Pi.createScopedModelPatternPersister({
|
|
@@ -611,9 +588,23 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
611
588
|
const agentStartWithDedupReset = Lifecycle.createAgentStartDedupHook(
|
|
612
589
|
agentLifecycleHooks.onAgentStart,
|
|
613
590
|
);
|
|
591
|
+
const compactionObserver = Lifecycle.createTelegramCompactionObserverRuntime({
|
|
592
|
+
setCompactionInProgress: lifecycle.setCompactionInProgress,
|
|
593
|
+
updateStatus,
|
|
594
|
+
requestDeferredDispatchNextQueuedTelegramTurn:
|
|
595
|
+
deferredQueueDispatchRuntime.request,
|
|
596
|
+
dispatchNextQueuedTelegramTurn,
|
|
597
|
+
recordRuntimeEvent,
|
|
598
|
+
});
|
|
614
599
|
Lifecycle.registerTelegramLifecycleHooks(pi, {
|
|
615
600
|
...sessionLifecycleRuntime,
|
|
616
601
|
...agentLifecycleHooks,
|
|
602
|
+
async onSessionShutdown(event, ctx) {
|
|
603
|
+
compactionObserver.onSessionShutdown();
|
|
604
|
+
await sessionLifecycleRuntime.onSessionShutdown(event, ctx);
|
|
605
|
+
},
|
|
606
|
+
onSessionBeforeCompact: compactionObserver.onSessionBeforeCompact,
|
|
607
|
+
onSessionCompact: compactionObserver.onSessionCompact,
|
|
617
608
|
onAgentStart: agentStartWithDedupReset,
|
|
618
609
|
onBeforeAgentStart: Prompts.createTelegramProactiveBeforeAgentStartHook({
|
|
619
610
|
isProactivePushEnabled,
|
package/lib/command-templates.ts
CHANGED
|
@@ -8,9 +8,8 @@ import { spawn } from "node:child_process";
|
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
9
|
import { isAbsolute, resolve } from "node:path";
|
|
10
10
|
|
|
11
|
-
export const DEFAULT_COMMAND_TIMEOUT_MS = 30_000;
|
|
12
|
-
|
|
13
11
|
export type CommandTemplateMode = "sequence" | "parallel";
|
|
12
|
+
export type CommandTemplateFailureScope = "continue" | "branch" | "root";
|
|
14
13
|
|
|
15
14
|
export interface CommandTemplateObjectConfig {
|
|
16
15
|
label?: string;
|
|
@@ -23,7 +22,9 @@ export interface CommandTemplateObjectConfig {
|
|
|
23
22
|
output?: string;
|
|
24
23
|
retry?: number;
|
|
25
24
|
critical?: boolean;
|
|
26
|
-
|
|
25
|
+
failure?: CommandTemplateFailureScope;
|
|
26
|
+
recover?: CommandTemplateValue;
|
|
27
|
+
repeat?: number | string;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
export type CommandTemplateValue = string | CommandTemplateConfig[] | CommandTemplateObjectConfig;
|
|
@@ -72,23 +73,92 @@ export function normalizeCommandTemplateConfig(
|
|
|
72
73
|
return typeof config === "string" ? { template: config } : config;
|
|
73
74
|
}
|
|
74
75
|
|
|
76
|
+
function normalizeRecoverConfig(
|
|
77
|
+
config: CommandTemplateValue | undefined,
|
|
78
|
+
): CommandTemplateConfig | undefined {
|
|
79
|
+
if (config === undefined) return undefined;
|
|
80
|
+
return Array.isArray(config) ? { template: config } : config;
|
|
81
|
+
}
|
|
82
|
+
|
|
75
83
|
function normalizeCommandTemplateDefaults(
|
|
76
84
|
defaults: Record<string, unknown> | undefined,
|
|
77
85
|
): Record<string, unknown> | undefined {
|
|
78
86
|
if (!defaults) return undefined;
|
|
79
87
|
const normalized: Record<string, unknown> = {};
|
|
80
88
|
for (const [key, value] of Object.entries(defaults)) {
|
|
81
|
-
normalized[key] =
|
|
82
|
-
|
|
89
|
+
normalized[key] = Array.isArray(value)
|
|
90
|
+
? value
|
|
91
|
+
: value === undefined || value === null ? "" : String(value);
|
|
83
92
|
}
|
|
84
93
|
return normalized;
|
|
85
94
|
}
|
|
86
95
|
|
|
87
|
-
function
|
|
96
|
+
export function resolveCommandTemplateRepeat(
|
|
97
|
+
value: number | string | undefined,
|
|
98
|
+
values: Record<string, unknown> = {},
|
|
99
|
+
): number | undefined {
|
|
88
100
|
if (value === undefined) return undefined;
|
|
89
|
-
if (
|
|
90
|
-
|
|
91
|
-
|
|
101
|
+
if (typeof value === "number") {
|
|
102
|
+
if (!Number.isInteger(value) || value < 1)
|
|
103
|
+
throw new Error("Command template repeat must be a positive integer.");
|
|
104
|
+
return value;
|
|
105
|
+
}
|
|
106
|
+
const trimmed = value.trim();
|
|
107
|
+
if (/^\d+$/.test(trimmed)) return Number(trimmed);
|
|
108
|
+
const lengthMatch = trimmed.match(/^\{?([A-Za-z_][A-Za-z0-9_-]*)\.length\}?$/);
|
|
109
|
+
if (lengthMatch) {
|
|
110
|
+
const source = values[lengthMatch[1]];
|
|
111
|
+
if (Array.isArray(source)) return source.length;
|
|
112
|
+
if (source === undefined) return undefined;
|
|
113
|
+
}
|
|
114
|
+
throw new Error("Command template repeat must be a positive integer or {array.length}.");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function getExecutableName(command: string | undefined): string {
|
|
118
|
+
if (!command) return "";
|
|
119
|
+
return command.split(/[\\/]/).pop()?.toLowerCase() ?? "";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function hasAnyFlag(args: string[], flags: string[]): boolean {
|
|
123
|
+
return args.some((arg) => flags.includes(arg));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function hasRiskyPathArg(args: string[]): boolean {
|
|
127
|
+
return args.some((arg) =>
|
|
128
|
+
arg === "/" ||
|
|
129
|
+
arg === "~" ||
|
|
130
|
+
arg === "./" ||
|
|
131
|
+
arg === "../" ||
|
|
132
|
+
arg.includes("{") ||
|
|
133
|
+
arg.startsWith("~/") ||
|
|
134
|
+
arg.startsWith("/"),
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function getLeafCommandTemplateWarnings(
|
|
139
|
+
config: CommandTemplateLeafConfig,
|
|
140
|
+
): string[] {
|
|
141
|
+
const parts = splitCommandTemplate(config.template);
|
|
142
|
+
const command = getExecutableName(parts[0]);
|
|
143
|
+
const args = parts.slice(1);
|
|
144
|
+
const warnings: string[] = [];
|
|
145
|
+
if (["bash", "sh", "zsh", "fish"].includes(command)) {
|
|
146
|
+
const mode = hasAnyFlag(args, ["-c"]) ? "shell command strings" : "shell scripts";
|
|
147
|
+
warnings.push(`${config.label ?? command}: invokes ${command}; ${mode} are trusted executable content and are not sandboxed by command-template argv splitting.`);
|
|
148
|
+
}
|
|
149
|
+
if (["node", "deno", "bun"].includes(command) && hasAnyFlag(args, ["-e", "--eval"])) {
|
|
150
|
+
warnings.push(`${config.label ?? command}: invokes ${command} eval mode; code strings are trusted executable content and are not sandboxed.`);
|
|
151
|
+
}
|
|
152
|
+
if (["python", "python3", "perl", "ruby"].includes(command) && hasAnyFlag(args, ["-c", "-e"])) {
|
|
153
|
+
warnings.push(`${config.label ?? command}: invokes ${command} code-eval mode; code strings are trusted executable content and are not sandboxed.`);
|
|
154
|
+
}
|
|
155
|
+
if (command === "rm" && (args.some((arg) => /^-[^-]*r/.test(arg) || /^-[^-]*f/.test(arg)) || hasRiskyPathArg(args))) {
|
|
156
|
+
warnings.push(`${config.label ?? command}: removes filesystem paths; verify placeholders and paths before running trusted destructive commands.`);
|
|
157
|
+
}
|
|
158
|
+
if (["mv", "cp", "rsync"].includes(command) && hasRiskyPathArg(args)) {
|
|
159
|
+
warnings.push(`${config.label ?? command}: mutates broad filesystem paths; verify placeholders and paths before running trusted commands.`);
|
|
160
|
+
}
|
|
161
|
+
return warnings;
|
|
92
162
|
}
|
|
93
163
|
|
|
94
164
|
function pad(value: number, width: number): string {
|
|
@@ -124,7 +194,7 @@ function expandRepeatConfig(
|
|
|
124
194
|
config: CommandTemplateObjectConfig,
|
|
125
195
|
context: Pick<CommandTemplateObjectConfig, "args" | "defaults">,
|
|
126
196
|
): CommandTemplateObjectConfig[] | undefined {
|
|
127
|
-
const repeat =
|
|
197
|
+
const repeat = resolveCommandTemplateRepeat(config.repeat, context.defaults ?? {});
|
|
128
198
|
if (repeat === undefined) return undefined;
|
|
129
199
|
return Array.from({ length: repeat }, (_unused, index0) => {
|
|
130
200
|
const { repeat: _repeat, ...rest } = config;
|
|
@@ -164,12 +234,19 @@ export function expandCommandTemplateConfigs(
|
|
|
164
234
|
if (repeated) {
|
|
165
235
|
return repeated.flatMap((step) => expandCommandTemplateConfigs(step, context));
|
|
166
236
|
}
|
|
237
|
+
const recoverConfig = normalizeRecoverConfig(normalizedConfig.recover);
|
|
238
|
+
const recoverSteps = recoverConfig
|
|
239
|
+
? expandCommandTemplateConfigs(recoverConfig, context)
|
|
240
|
+
: [];
|
|
167
241
|
if (Array.isArray(normalizedConfig.template)) {
|
|
168
|
-
return
|
|
169
|
-
|
|
170
|
-
|
|
242
|
+
return [
|
|
243
|
+
...normalizedConfig.template.flatMap((step) =>
|
|
244
|
+
expandCommandTemplateConfigs(step, context),
|
|
245
|
+
),
|
|
246
|
+
...recoverSteps,
|
|
247
|
+
];
|
|
171
248
|
}
|
|
172
|
-
if (typeof normalizedConfig.template !== "string") return
|
|
249
|
+
if (typeof normalizedConfig.template !== "string") return recoverSteps;
|
|
173
250
|
return [
|
|
174
251
|
{
|
|
175
252
|
...normalizedConfig,
|
|
@@ -178,9 +255,37 @@ export function expandCommandTemplateConfigs(
|
|
|
178
255
|
retry: normalizedConfig.retry,
|
|
179
256
|
critical: normalizedConfig.critical,
|
|
180
257
|
},
|
|
258
|
+
...recoverSteps,
|
|
181
259
|
];
|
|
182
260
|
}
|
|
183
261
|
|
|
262
|
+
export function getCommandTemplateWarnings(
|
|
263
|
+
config: CommandTemplateConfig,
|
|
264
|
+
): string[] {
|
|
265
|
+
return [
|
|
266
|
+
...new Set(
|
|
267
|
+
expandCommandTemplateConfigs(config)
|
|
268
|
+
.flatMap((leaf) => getLeafCommandTemplateWarnings(leaf)),
|
|
269
|
+
),
|
|
270
|
+
];
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function parseCommandTemplateArgToken(value: string): { name: string; defaultValue?: string } {
|
|
274
|
+
const separatorIndex = value.indexOf("=");
|
|
275
|
+
const rawName = separatorIndex === -1 ? value : value.slice(0, separatorIndex);
|
|
276
|
+
const colonIndex = rawName.indexOf(":");
|
|
277
|
+
return {
|
|
278
|
+
name: (colonIndex === -1 ? rawName : rawName.slice(0, colonIndex)).trim(),
|
|
279
|
+
...(separatorIndex === -1 ? {} : { defaultValue: value.slice(separatorIndex + 1).trim() }),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function parseCommandTemplatePlaceholderContent(content: string): { name: string; inlineDefault?: string } | undefined {
|
|
284
|
+
const match = content.match(/^([A-Za-z_][A-Za-z0-9_-]*)(?::(?:string|path|int|number|bool|array|enum\([^)]*\)))?(?:=([^}]*))?$/);
|
|
285
|
+
if (!match) return undefined;
|
|
286
|
+
return { name: match[1], ...(match[2] !== undefined ? { inlineDefault: match[2] } : {}) };
|
|
287
|
+
}
|
|
288
|
+
|
|
184
289
|
export function getCommandTemplateDefaults(
|
|
185
290
|
config: CommandTemplateConfig | undefined,
|
|
186
291
|
): Record<string, string> {
|
|
@@ -190,9 +295,9 @@ export function getCommandTemplateDefaults(
|
|
|
190
295
|
const defaults: Record<string, string> = {};
|
|
191
296
|
for (const item of normalizeCommandTemplateArgs(normalizedConfig?.args)) {
|
|
192
297
|
if (!item) continue;
|
|
193
|
-
const
|
|
194
|
-
if (!name ||
|
|
195
|
-
defaults[name
|
|
298
|
+
const parsed = parseCommandTemplateArgToken(item);
|
|
299
|
+
if (!parsed.name || parsed.defaultValue === undefined) continue;
|
|
300
|
+
defaults[parsed.name] = parsed.defaultValue;
|
|
196
301
|
}
|
|
197
302
|
for (const [key, value] of Object.entries(normalizedConfig?.defaults ?? {})) {
|
|
198
303
|
defaults[key] = value === undefined || value === null ? "" : String(value);
|
|
@@ -256,7 +361,7 @@ export function expandCommandTemplateExecutable(
|
|
|
256
361
|
|
|
257
362
|
function evaluateCommandTemplateExpression(
|
|
258
363
|
expression: string,
|
|
259
|
-
values: Record<string,
|
|
364
|
+
values: Record<string, unknown>,
|
|
260
365
|
): number {
|
|
261
366
|
let index = 0;
|
|
262
367
|
const source = expression.replace(/\s+/g, "");
|
|
@@ -283,7 +388,7 @@ function evaluateCommandTemplateExpression(
|
|
|
283
388
|
if (nameMatch) {
|
|
284
389
|
index += nameMatch[0].length;
|
|
285
390
|
const value = values[nameMatch[0]];
|
|
286
|
-
if (value === undefined || !/^-?\d+$/.test(value))
|
|
391
|
+
if (value === undefined || !/^-?\d+$/.test(String(value)))
|
|
287
392
|
throw new Error(`Invalid command template expression variable: ${nameMatch[0]}`);
|
|
288
393
|
return Number(value);
|
|
289
394
|
}
|
|
@@ -313,7 +418,7 @@ function evaluateCommandTemplateExpression(
|
|
|
313
418
|
|
|
314
419
|
function substituteCommandTemplateExpression(
|
|
315
420
|
content: string,
|
|
316
|
-
values: Record<string,
|
|
421
|
+
values: Record<string, unknown>,
|
|
317
422
|
): string | undefined {
|
|
318
423
|
const padded = content.match(/^(_{1,6})\((.+)\)$/);
|
|
319
424
|
if (padded) {
|
|
@@ -323,22 +428,50 @@ function substituteCommandTemplateExpression(
|
|
|
323
428
|
return String(evaluateCommandTemplateExpression(content, values));
|
|
324
429
|
}
|
|
325
430
|
|
|
431
|
+
function resolveCommandTemplateValue(
|
|
432
|
+
content: string,
|
|
433
|
+
values: Record<string, unknown>,
|
|
434
|
+
missingLabel: string,
|
|
435
|
+
depth = 0,
|
|
436
|
+
): string | undefined {
|
|
437
|
+
if (depth > 5) throw new Error(`Command template value recursion exceeded: ${content}`);
|
|
438
|
+
const indexed = content.match(/^([A-Za-z_][A-Za-z0-9_-]*)\[([A-Za-z_][A-Za-z0-9_-]*|\d+)\]$/);
|
|
439
|
+
if (indexed) {
|
|
440
|
+
const source = values[indexed[1]];
|
|
441
|
+
const indexValue = /^\d+$/.test(indexed[2]) ? indexed[2] : values[indexed[2]];
|
|
442
|
+
const index = Number(indexValue);
|
|
443
|
+
if (!Array.isArray(source) || !Number.isInteger(index) || index < 0 || index >= source.length) {
|
|
444
|
+
throw new Error(`Missing ${missingLabel} value: ${content}`);
|
|
445
|
+
}
|
|
446
|
+
return String(source[index] ?? "");
|
|
447
|
+
}
|
|
448
|
+
const simple = parseCommandTemplatePlaceholderContent(content);
|
|
449
|
+
if (simple) {
|
|
450
|
+
if (Object.hasOwn(values, simple.name)) {
|
|
451
|
+
const raw = values[simple.name] ?? "";
|
|
452
|
+
if (typeof raw === "string" && /^\{[^{}]+\}$/.test(raw)) {
|
|
453
|
+
return substituteCommandTemplateToken(raw, values, missingLabel, depth + 1);
|
|
454
|
+
}
|
|
455
|
+
return Array.isArray(raw) ? JSON.stringify(raw) : String(raw);
|
|
456
|
+
}
|
|
457
|
+
if (simple.inlineDefault !== undefined) return simple.inlineDefault;
|
|
458
|
+
}
|
|
459
|
+
const expression = substituteCommandTemplateExpression(content, values);
|
|
460
|
+
if (expression !== undefined) return expression;
|
|
461
|
+
return undefined;
|
|
462
|
+
}
|
|
463
|
+
|
|
326
464
|
export function substituteCommandTemplateToken(
|
|
327
465
|
token: string,
|
|
328
|
-
values: Record<string,
|
|
466
|
+
values: Record<string, unknown>,
|
|
329
467
|
missingLabel = "command template",
|
|
468
|
+
depth = 0,
|
|
330
469
|
): string {
|
|
331
470
|
return token.replace(
|
|
332
471
|
/\{([^{}]+)\}/g,
|
|
333
472
|
(_match, content: string) => {
|
|
334
|
-
const
|
|
335
|
-
if (
|
|
336
|
-
const [, name, inlineDefault] = simple;
|
|
337
|
-
if (Object.hasOwn(values, name)) return values[name] ?? "";
|
|
338
|
-
if (inlineDefault !== undefined) return inlineDefault;
|
|
339
|
-
}
|
|
340
|
-
const expression = substituteCommandTemplateExpression(content, values);
|
|
341
|
-
if (expression !== undefined) return expression;
|
|
473
|
+
const resolved = resolveCommandTemplateValue(content, values, missingLabel, depth);
|
|
474
|
+
if (resolved !== undefined) return resolved;
|
|
342
475
|
throw new Error(`Missing ${missingLabel} value: ${content}`);
|
|
343
476
|
},
|
|
344
477
|
);
|
|
@@ -405,8 +538,6 @@ function execCommandTemplateOnce(
|
|
|
405
538
|
}
|
|
406
539
|
if (options.timeout !== undefined && options.timeout > 0)
|
|
407
540
|
timeoutId = setTimeout(killProcess, options.timeout);
|
|
408
|
-
else if (options.timeout === undefined)
|
|
409
|
-
timeoutId = setTimeout(killProcess, DEFAULT_COMMAND_TIMEOUT_MS);
|
|
410
541
|
proc.stdout?.on("data", (data) => {
|
|
411
542
|
stdout += data.toString();
|
|
412
543
|
});
|
|
@@ -427,7 +558,7 @@ function execCommandTemplateOnce(
|
|
|
427
558
|
|
|
428
559
|
export function buildCommandTemplateInvocation(
|
|
429
560
|
config: CommandTemplateConfig,
|
|
430
|
-
values: Record<string,
|
|
561
|
+
values: Record<string, unknown>,
|
|
431
562
|
cwd: string,
|
|
432
563
|
options: { emptyMessage?: string; missingLabel?: string } = {},
|
|
433
564
|
): CommandTemplateInvocation {
|