opencode-overclock 0.2.2 → 0.4.0
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/README.md +285 -81
- package/package.json +3 -3
- package/src/bridge.ts +1 -0
- package/src/buddy/companion.ts +104 -5
- package/src/buddy/sprites.ts +4 -4
- package/src/buddy/tui.ts +175 -65
- package/src/core/bridge.ts +34 -0
- package/src/core/lifecycle.ts +53 -0
- package/src/core/policy.ts +128 -0
- package/src/core/summary.ts +33 -0
- package/src/core/types.ts +164 -0
- package/src/features/buddy.ts +1 -2
- package/src/features/guard.ts +168 -30
- package/src/features/index.ts +6 -4
- package/src/features/recovery.ts +143 -0
- package/src/features/sched.ts +147 -89
- package/src/features/tasks.ts +54 -20
- package/src/features/truncator.ts +99 -0
- package/src/features/usage.ts +26 -65
- package/src/index.ts +98 -55
- package/src/lib/busy.ts +1 -25
- package/src/lib/exec.ts +7 -0
- package/src/lib/inject.ts +10 -56
- package/src/lib/mirror.ts +13 -0
- package/src/lib/probe.ts +1 -15
- package/src/lib/state.ts +10 -39
- package/src/lib/tmux.ts +1 -0
- package/src/lib/ui.ts +208 -0
- package/src/merge.ts +2 -35
- package/src/platform/probe.ts +25 -0
- package/src/platform/process/exec.ts +76 -0
- package/src/platform/process/tmux.ts +60 -0
- package/src/platform/session/busy.ts +33 -0
- package/src/platform/session/inject.ts +82 -0
- package/src/platform/session/notify.ts +20 -0
- package/src/platform/storage/state.ts +77 -0
- package/src/platform/storage/store.ts +61 -0
- package/src/summary.ts +1 -0
- package/src/tools.ts +8 -0
- package/src/tui.ts +57 -186
- package/src/types.ts +1 -39
- package/src/v2/context.ts +470 -0
- package/src/v2/host.ts +117 -0
- package/src/v2/loader.ts +150 -0
- package/src/buddy/reactions.ts +0 -41
- package/src/buddy/types.ts +0 -30
- package/src/config.ts +0 -19
- package/src/features/checkpoints.ts +0 -128
- package/src/features/sandbox.ts +0 -104
- package/src/validate.ts +0 -143
package/src/features/sched.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { tool } from "@opencode-ai/plugin"
|
|
2
2
|
import { Cron } from "croner"
|
|
3
|
-
import type { FeatureModule } from "../types.ts"
|
|
3
|
+
import type { FeatureModule, SchedOptions } from "../types.ts"
|
|
4
|
+
import type { BusyTracker } from "../core/types.ts"
|
|
4
5
|
import { ensureStateDir, readJson, writeJson } from "../lib/state.ts"
|
|
6
|
+
import { scheduleStore, type ScheduleEntry } from "../lib/mirror.ts"
|
|
5
7
|
import { inject, toast } from "../lib/inject.ts"
|
|
6
8
|
|
|
7
9
|
const z = tool.schema
|
|
@@ -18,94 +20,157 @@ export function parseSpec(spec: string): Spec {
|
|
|
18
20
|
return { kind: "cron", expr: spec }
|
|
19
21
|
}
|
|
20
22
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
23
|
+
export type Schedule = ScheduleEntry
|
|
24
|
+
|
|
25
|
+
export interface ScheduleManager {
|
|
26
|
+
readonly schedules: Map<string, ScheduleEntry>
|
|
27
|
+
create(input: {
|
|
28
|
+
spec: string
|
|
29
|
+
prompt: string
|
|
30
|
+
target: "current" | "new-session"
|
|
31
|
+
sessionID: string
|
|
32
|
+
}): Promise<{ schedule: ScheduleEntry; next: string }>
|
|
33
|
+
list(): Array<{ schedule: ScheduleEntry; next: string }>
|
|
34
|
+
delete(id: string): Promise<boolean>
|
|
35
|
+
arm(s: ScheduleEntry): void
|
|
36
|
+
disarm(id: string): void
|
|
37
|
+
nextRun(s: ScheduleEntry): string
|
|
38
|
+
dispose(): void
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ScheduleManagerDeps {
|
|
42
|
+
storePath: string
|
|
43
|
+
client: any
|
|
44
|
+
busy?: BusyTracker
|
|
45
|
+
skipIfBusy?: boolean
|
|
28
46
|
}
|
|
29
47
|
|
|
30
48
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
49
|
+
* Encapsulated schedule manager handling timer registration, persistence,
|
|
50
|
+
* and dispatching prompt injections.
|
|
33
51
|
*/
|
|
34
|
-
export
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const persist = () => writeJson(storePath, [...schedules.values()])
|
|
48
|
-
|
|
49
|
-
async function fire(s: Schedule) {
|
|
50
|
-
try {
|
|
51
|
-
if (s.target === "current") {
|
|
52
|
-
// target still chewing on previous turn -> skip this fire, no pileup
|
|
53
|
-
if (skipIfBusy && shared.busy.isBusy(s.sessionID)) {
|
|
54
|
-
await toast(ctx.client, `schedule ${s.id} skipped (session busy)`, "info")
|
|
55
|
-
return
|
|
56
|
-
}
|
|
57
|
-
const ok = await inject(ctx.client, s.sessionID, `[schedule ${s.id} fired]\n${s.prompt}`)
|
|
58
|
-
if (!ok) await toast(ctx.client, `schedule ${s.id}: target session gone`, "warning")
|
|
59
|
-
} else {
|
|
60
|
-
const res = await ctx.client.session.create({ body: { title: `sched:${s.id}` } })
|
|
61
|
-
const id = res.data?.id
|
|
62
|
-
if (!id) throw new Error("session.create returned no id")
|
|
63
|
-
await inject(ctx.client, id, s.prompt)
|
|
64
|
-
await toast(ctx.client, `schedule ${s.id} fired -> new session`, "info")
|
|
52
|
+
export async function createScheduleManager(deps: ScheduleManagerDeps): Promise<ScheduleManager> {
|
|
53
|
+
const schedules = new Map<string, ScheduleEntry>()
|
|
54
|
+
const timers = new Map<string, { stop(): void }>()
|
|
55
|
+
const skipIfBusy = deps.skipIfBusy !== false
|
|
56
|
+
|
|
57
|
+
const persist = async () => writeJson(deps.storePath, [...schedules.values()])
|
|
58
|
+
|
|
59
|
+
async function fire(s: ScheduleEntry) {
|
|
60
|
+
try {
|
|
61
|
+
if (s.target === "current") {
|
|
62
|
+
if (skipIfBusy && deps.busy?.isBusy(s.sessionID)) {
|
|
63
|
+
await toast(deps.client, `schedule ${s.id} skipped (session busy)`, "info")
|
|
64
|
+
return
|
|
65
65
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
66
|
+
const ok = await inject(deps.client, s.sessionID, `[schedule ${s.id} fired]\n${s.prompt}`)
|
|
67
|
+
if (!ok) await toast(deps.client, `schedule ${s.id}: target session gone`, "warning")
|
|
68
|
+
} else {
|
|
69
|
+
const res = await deps.client.session.create({ body: { title: `sched:${s.id}` } })
|
|
70
|
+
const id = res.data?.id
|
|
71
|
+
if (!id) throw new Error("session.create returned no id")
|
|
72
|
+
await inject(deps.client, id, s.prompt)
|
|
73
|
+
await toast(deps.client, `schedule ${s.id} fired -> new session`, "info")
|
|
69
74
|
}
|
|
75
|
+
} catch (e) {
|
|
76
|
+
console.warn(`[overclock] schedule ${s.id} fire failed: ${e}`)
|
|
77
|
+
await toast(deps.client, `schedule ${s.id} failed: ${e}`, "error")
|
|
70
78
|
}
|
|
79
|
+
}
|
|
71
80
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
+
function arm(s: ScheduleEntry) {
|
|
82
|
+
const spec = parseSpec(s.spec)
|
|
83
|
+
const timer =
|
|
84
|
+
spec.kind === "interval"
|
|
85
|
+
? {
|
|
86
|
+
stop: clearInterval.bind(
|
|
87
|
+
null,
|
|
88
|
+
setInterval(() => fire(s), spec.ms),
|
|
89
|
+
),
|
|
90
|
+
}
|
|
91
|
+
: new Cron(spec.expr, () => fire(s))
|
|
92
|
+
timers.set(s.id, timer)
|
|
93
|
+
}
|
|
81
94
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
95
|
+
function disarm(id: string) {
|
|
96
|
+
timers.get(id)?.stop()
|
|
97
|
+
timers.delete(id)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function nextRun(s: ScheduleEntry): string {
|
|
101
|
+
const spec = parseSpec(s.spec)
|
|
102
|
+
if (spec.kind === "cron") return new Cron(spec.expr).nextRun()?.toISOString() ?? "never"
|
|
103
|
+
return `every ${s.spec}`
|
|
104
|
+
}
|
|
88
105
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
106
|
+
const stored = await readJson<ScheduleEntry[]>(deps.storePath, [])
|
|
107
|
+
for (const s of stored) {
|
|
108
|
+
schedules.set(s.id, s)
|
|
109
|
+
try {
|
|
110
|
+
arm(s)
|
|
111
|
+
} catch (e) {
|
|
112
|
+
console.warn(`[overclock] rearm ${s.id} failed: ${e}`)
|
|
93
113
|
}
|
|
114
|
+
}
|
|
94
115
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
116
|
+
return {
|
|
117
|
+
schedules,
|
|
118
|
+
arm,
|
|
119
|
+
disarm,
|
|
120
|
+
nextRun,
|
|
121
|
+
async create(input) {
|
|
122
|
+
parseSpec(input.spec)
|
|
123
|
+
const s: ScheduleEntry = {
|
|
124
|
+
id: `s-${crypto.randomUUID().slice(0, 6)}`,
|
|
125
|
+
spec: input.spec,
|
|
126
|
+
prompt: input.prompt,
|
|
127
|
+
target: input.target,
|
|
128
|
+
sessionID: input.sessionID,
|
|
129
|
+
createdAt: new Date().toISOString(),
|
|
102
130
|
}
|
|
103
|
-
|
|
131
|
+
schedules.set(s.id, s)
|
|
132
|
+
arm(s)
|
|
133
|
+
await persist()
|
|
134
|
+
return { schedule: s, next: nextRun(s) }
|
|
135
|
+
},
|
|
136
|
+
list() {
|
|
137
|
+
return [...schedules.values()].map((s) => ({ schedule: s, next: nextRun(s) }))
|
|
138
|
+
},
|
|
139
|
+
async delete(id: string) {
|
|
140
|
+
if (!schedules.delete(id)) return false
|
|
141
|
+
disarm(id)
|
|
142
|
+
await persist()
|
|
143
|
+
return true
|
|
144
|
+
},
|
|
145
|
+
dispose() {
|
|
146
|
+
for (const id of [...timers.keys()]) disarm(id)
|
|
147
|
+
},
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Scheduled runs: cron exprs or plain intervals ("5m"). interval + current session = /loop.
|
|
153
|
+
* Persisted, rearmed on startup.
|
|
154
|
+
*/
|
|
155
|
+
export const sched: FeatureModule = {
|
|
156
|
+
name: "sched",
|
|
157
|
+
tools: ["schedule_create", "schedule_list", "schedule_delete"],
|
|
158
|
+
defaultEnabled: true,
|
|
159
|
+
requires: ["session.promptAsync", "session.messages", "session.create"],
|
|
160
|
+
async init(ctx, rawOptions, shared) {
|
|
161
|
+
const options = (rawOptions ?? {}) as SchedOptions
|
|
162
|
+
await ensureStateDir(ctx.directory)
|
|
163
|
+
const storePath = scheduleStore.path(ctx.directory)
|
|
164
|
+
|
|
165
|
+
const manager = await createScheduleManager({
|
|
166
|
+
storePath,
|
|
167
|
+
client: ctx.client,
|
|
168
|
+
busy: shared.busy,
|
|
169
|
+
skipIfBusy: options.skipIfBusy !== false,
|
|
170
|
+
})
|
|
104
171
|
|
|
105
172
|
return {
|
|
106
|
-
dispose: async () =>
|
|
107
|
-
for (const id of [...timers.keys()]) disarm(id)
|
|
108
|
-
},
|
|
173
|
+
dispose: async () => manager.dispose(),
|
|
109
174
|
tool: {
|
|
110
175
|
schedule_create: tool({
|
|
111
176
|
description:
|
|
@@ -116,31 +181,25 @@ export const sched: FeatureModule = {
|
|
|
116
181
|
target: z.enum(["current", "new-session"]).default("current"),
|
|
117
182
|
},
|
|
118
183
|
async execute(args, tctx) {
|
|
119
|
-
|
|
120
|
-
const s: Schedule = {
|
|
121
|
-
id: `s-${crypto.randomUUID().slice(0, 6)}`,
|
|
184
|
+
const { schedule, next } = await manager.create({
|
|
122
185
|
spec: args.spec,
|
|
123
186
|
prompt: args.prompt,
|
|
124
187
|
target: args.target,
|
|
125
188
|
sessionID: tctx.sessionID,
|
|
126
|
-
|
|
127
|
-
}
|
|
128
|
-
schedules.set(s.id, s)
|
|
129
|
-
arm(s)
|
|
130
|
-
await persist()
|
|
131
|
-
return `created ${s.id}: ${args.spec} -> ${args.target} (next: ${nextRun(s)})`
|
|
189
|
+
})
|
|
190
|
+
return `created ${schedule.id}: ${args.spec} -> ${args.target} (next: ${next})`
|
|
132
191
|
},
|
|
133
192
|
}),
|
|
134
193
|
schedule_list: tool({
|
|
135
194
|
description: "List schedules.",
|
|
136
195
|
args: {},
|
|
137
196
|
async execute() {
|
|
138
|
-
const all =
|
|
197
|
+
const all = manager.list()
|
|
139
198
|
if (!all.length) return "no schedules"
|
|
140
199
|
return all
|
|
141
200
|
.map(
|
|
142
|
-
(s) =>
|
|
143
|
-
`${s.id} [${s.spec}] -> ${s.target} (next: ${
|
|
201
|
+
({ schedule: s, next }) =>
|
|
202
|
+
`${s.id} [${s.spec}] -> ${s.target} (next: ${next}) :: ${s.prompt.slice(0, 60)}`,
|
|
144
203
|
)
|
|
145
204
|
.join("\n")
|
|
146
205
|
},
|
|
@@ -149,9 +208,8 @@ export const sched: FeatureModule = {
|
|
|
149
208
|
description: "Delete a schedule by id.",
|
|
150
209
|
args: { id: z.string() },
|
|
151
210
|
async execute(args) {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
await persist()
|
|
211
|
+
const ok = await manager.delete(args.id)
|
|
212
|
+
if (!ok) return `no schedule ${args.id}`
|
|
155
213
|
return `deleted ${args.id}`
|
|
156
214
|
},
|
|
157
215
|
}),
|
package/src/features/tasks.ts
CHANGED
|
@@ -2,7 +2,10 @@ import { stat } from "node:fs/promises"
|
|
|
2
2
|
import { tool } from "@opencode-ai/plugin"
|
|
3
3
|
import type { FeatureModule } from "../types.ts"
|
|
4
4
|
import { ensureStateDir, shellQuote, writeJson } from "../lib/state.ts"
|
|
5
|
+
import { taskStore, type TaskMirrorEntry } from "../lib/mirror.ts"
|
|
5
6
|
import { inject, toast } from "../lib/inject.ts"
|
|
7
|
+
import { NON_INTERACTIVE_ENV } from "../lib/exec.ts"
|
|
8
|
+
import { spawnTaskPane, type TmuxPane } from "../lib/tmux.ts"
|
|
6
9
|
|
|
7
10
|
const z = tool.schema
|
|
8
11
|
|
|
@@ -16,6 +19,22 @@ const PROMPT_PATTERNS = [
|
|
|
16
19
|
/Overwrite\?/i,
|
|
17
20
|
]
|
|
18
21
|
|
|
22
|
+
const INTERACTIVE_COMMAND_PATTERNS = [
|
|
23
|
+
/^\s*(?:vi|vim|nvim|nano|pico|emacs)\b/i,
|
|
24
|
+
/^\s*git\s+(?:rebase\s+-i|commit\s+--amend(?!\s+-m))/i,
|
|
25
|
+
/^\s*git\s+add\s+-p\b/i,
|
|
26
|
+
/^\s*(?:python|python3|node|irb|ghci|bash|sh|zsh)\s*$/i,
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
/** Detects if a command is explicitly interactive (e.g. editor, rebase -i, bare REPL). */
|
|
30
|
+
export function detectInteractiveCommand(command: string): string | null {
|
|
31
|
+
for (const pattern of INTERACTIVE_COMMAND_PATTERNS) {
|
|
32
|
+
const match = command.match(pattern)
|
|
33
|
+
if (match) return match[0].trim()
|
|
34
|
+
}
|
|
35
|
+
return null
|
|
36
|
+
}
|
|
37
|
+
|
|
19
38
|
/** Last non-empty line of `tail` looks like an interactive y/n or press-key prompt. */
|
|
20
39
|
export function looksLikePrompt(tail: string): boolean {
|
|
21
40
|
const lastLine = tail.trimEnd().split("\n").pop() ?? ""
|
|
@@ -39,14 +58,7 @@ interface TaskEntry extends TaskRecord {
|
|
|
39
58
|
stallTimer?: ReturnType<typeof setInterval>
|
|
40
59
|
stallNotified: boolean
|
|
41
60
|
startedAt: number
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
interface TaskMirrorEntry {
|
|
45
|
-
id: string
|
|
46
|
-
description: string
|
|
47
|
-
status: TaskRecord["status"]
|
|
48
|
-
exitCode: number | null
|
|
49
|
-
startedAt: number
|
|
61
|
+
tmuxPane?: TmuxPane
|
|
50
62
|
}
|
|
51
63
|
|
|
52
64
|
export interface TaskManager {
|
|
@@ -95,7 +107,8 @@ function startStallWatchdog(
|
|
|
95
107
|
lastGrowth = Date.now()
|
|
96
108
|
return
|
|
97
109
|
}
|
|
98
|
-
if (Date.now() - lastGrowth < thresholdMs || entry.stallNotified)
|
|
110
|
+
if (Date.now() - lastGrowth < thresholdMs || entry.stallNotified || entry.status !== "running")
|
|
111
|
+
return
|
|
99
112
|
const file = Bun.file(entry.logPath)
|
|
100
113
|
const start = Math.max(0, s.size - tailBytes)
|
|
101
114
|
const tail = await file.slice(start).text()
|
|
@@ -125,6 +138,8 @@ export function createTaskManager(opts: {
|
|
|
125
138
|
stallCheckIntervalMs?: number
|
|
126
139
|
stallThresholdMs?: number
|
|
127
140
|
stallTailBytes?: number
|
|
141
|
+
/** spawn a tmux split pane to tail task logs (only if TMUX is active) */
|
|
142
|
+
tmux?: boolean
|
|
128
143
|
}): TaskManager {
|
|
129
144
|
const tasks = new Map<string, TaskEntry>()
|
|
130
145
|
let counter = 0
|
|
@@ -153,6 +168,10 @@ export function createTaskManager(opts: {
|
|
|
153
168
|
// shell-level redirection: no piping code, survives plugin restart losing streams
|
|
154
169
|
const proc = Bun.spawn(["bash", "-c", `(${input.command}) >> ${shellQuote(logPath)} 2>&1`], {
|
|
155
170
|
cwd: input.cwd,
|
|
171
|
+
env: {
|
|
172
|
+
...process.env,
|
|
173
|
+
...NON_INTERACTIVE_ENV,
|
|
174
|
+
},
|
|
156
175
|
})
|
|
157
176
|
const entry: TaskEntry = {
|
|
158
177
|
id,
|
|
@@ -169,6 +188,13 @@ export function createTaskManager(opts: {
|
|
|
169
188
|
}
|
|
170
189
|
tasks.set(id, entry)
|
|
171
190
|
persistMirror()
|
|
191
|
+
let panePromise: Promise<TmuxPane | null> | undefined
|
|
192
|
+
if (opts.tmux) {
|
|
193
|
+
panePromise = spawnTaskPane(entry.logPath, entry.description)
|
|
194
|
+
panePromise.then((pane) => {
|
|
195
|
+
if (pane) entry.tmuxPane = pane
|
|
196
|
+
})
|
|
197
|
+
}
|
|
172
198
|
if (input.timeoutMs) {
|
|
173
199
|
entry.timeoutTimer = setTimeout(() => kill(id), input.timeoutMs)
|
|
174
200
|
}
|
|
@@ -181,9 +207,15 @@ export function createTaskManager(opts: {
|
|
|
181
207
|
opts.onStall,
|
|
182
208
|
)
|
|
183
209
|
}
|
|
184
|
-
proc.exited.then((code) => {
|
|
210
|
+
proc.exited.then(async (code) => {
|
|
185
211
|
if (entry.timeoutTimer) clearTimeout(entry.timeoutTimer)
|
|
186
212
|
if (entry.stallTimer) clearInterval(entry.stallTimer)
|
|
213
|
+
if (panePromise) {
|
|
214
|
+
const pane = await panePromise
|
|
215
|
+
pane?.close()
|
|
216
|
+
} else if (entry.tmuxPane) {
|
|
217
|
+
void entry.tmuxPane.close()
|
|
218
|
+
}
|
|
187
219
|
if (entry.status === "running") entry.status = "exited"
|
|
188
220
|
entry.exitCode = code
|
|
189
221
|
persistMirror()
|
|
@@ -199,6 +231,7 @@ export function createTaskManager(opts: {
|
|
|
199
231
|
persistMirror()
|
|
200
232
|
if (entry.stallTimer) clearInterval(entry.stallTimer)
|
|
201
233
|
entry.stallTimer = undefined
|
|
234
|
+
if (entry.tmuxPane) void entry.tmuxPane.close()
|
|
202
235
|
entry.proc.kill("SIGTERM")
|
|
203
236
|
const hard = setTimeout(() => entry.proc.kill("SIGKILL"), 3000)
|
|
204
237
|
entry.proc.exited.then(() => clearTimeout(hard))
|
|
@@ -237,17 +270,10 @@ const fmt = (t: TaskRecord) =>
|
|
|
237
270
|
export const tasks: FeatureModule = {
|
|
238
271
|
name: "tasks",
|
|
239
272
|
tools: ["task_run", "task_status", "task_output", "task_kill"],
|
|
240
|
-
options: {
|
|
241
|
-
killOnExit: "boolean",
|
|
242
|
-
stallDetection: "boolean",
|
|
243
|
-
stallThresholdMs: "number",
|
|
244
|
-
stallCheckIntervalMs: "number",
|
|
245
|
-
},
|
|
246
273
|
defaultEnabled: true,
|
|
247
274
|
requires: ["session.promptAsync", "session.messages"],
|
|
248
|
-
async init(ctx, options) {
|
|
275
|
+
async init(ctx, options, shared) {
|
|
249
276
|
const logDir = await ensureStateDir(ctx.directory, "tasks")
|
|
250
|
-
const stateDir = await ensureStateDir(ctx.directory)
|
|
251
277
|
const killOnExit = options.killOnExit !== false
|
|
252
278
|
const stallDetection = options.stallDetection !== false
|
|
253
279
|
const stallThresholdMs =
|
|
@@ -257,7 +283,8 @@ export const tasks: FeatureModule = {
|
|
|
257
283
|
|
|
258
284
|
const manager = createTaskManager({
|
|
259
285
|
logDir,
|
|
260
|
-
mirrorPath:
|
|
286
|
+
mirrorPath: taskStore.path(ctx.directory),
|
|
287
|
+
tmux: options.tmux === true,
|
|
261
288
|
onExit: async (task) => {
|
|
262
289
|
if (task.status === "killed") return
|
|
263
290
|
const tail = await manager.output(task.id, 20)
|
|
@@ -284,7 +311,7 @@ export const tasks: FeatureModule = {
|
|
|
284
311
|
task.sessionID,
|
|
285
312
|
`[background task ${task.id} "${task.description}" appears to be waiting for interactive input]\n` +
|
|
286
313
|
`last output:\n${tail.trimEnd()}\n\n` +
|
|
287
|
-
`The command is likely blocked on a prompt. Kill it with task_kill and re-run non-interactively ` +
|
|
314
|
+
`The command is likely blocked on a prompt. Kill it with ${shared.toolName("task_kill")} and re-run non-interactively ` +
|
|
288
315
|
`(e.g. pipe input like \`echo y | cmd\`, or pass a --yes/--force flag).`,
|
|
289
316
|
)
|
|
290
317
|
},
|
|
@@ -293,6 +320,9 @@ export const tasks: FeatureModule = {
|
|
|
293
320
|
})
|
|
294
321
|
|
|
295
322
|
return {
|
|
323
|
+
"shell.env": async (_input, output) => {
|
|
324
|
+
Object.assign(output.env, NON_INTERACTIVE_ENV)
|
|
325
|
+
},
|
|
296
326
|
dispose: async () => {
|
|
297
327
|
if (killOnExit) manager.killAll()
|
|
298
328
|
},
|
|
@@ -307,6 +337,10 @@ export const tasks: FeatureModule = {
|
|
|
307
337
|
timeout: z.number().optional().describe("seconds until auto-kill"),
|
|
308
338
|
},
|
|
309
339
|
async execute(args, tctx) {
|
|
340
|
+
const blocked = detectInteractiveCommand(args.command)
|
|
341
|
+
if (blocked) {
|
|
342
|
+
return `Error: Command '${args.command}' appears to require interactive input (${blocked}). Background tasks run non-interactively and will hang on prompts.`
|
|
343
|
+
}
|
|
310
344
|
const task = manager.run({
|
|
311
345
|
command: args.command,
|
|
312
346
|
description: args.description,
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { FeatureModule } from "../types.ts"
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_TRUNCATABLE_TOOLS = ["task_output", "bash", "grep", "glob", "webfetch"]
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_MAX_CHARS = 40_000
|
|
6
|
+
export const DEFAULT_HEAD_LINES = 10
|
|
7
|
+
export const DEFAULT_TAIL_LINES = 30
|
|
8
|
+
|
|
9
|
+
export interface TruncateResult {
|
|
10
|
+
text: string
|
|
11
|
+
truncated: boolean
|
|
12
|
+
omittedLines: number
|
|
13
|
+
omittedChars: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Smartly truncate output preserving top context and bottom tail.
|
|
18
|
+
*/
|
|
19
|
+
export function truncateOutput(
|
|
20
|
+
content: string,
|
|
21
|
+
maxChars = DEFAULT_MAX_CHARS,
|
|
22
|
+
headLinesCount = DEFAULT_HEAD_LINES,
|
|
23
|
+
tailLinesCount = DEFAULT_TAIL_LINES,
|
|
24
|
+
): TruncateResult {
|
|
25
|
+
if (content.length <= maxChars) {
|
|
26
|
+
return { text: content, truncated: false, omittedLines: 0, omittedChars: 0 }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const lines = content.split("\n")
|
|
30
|
+
if (lines.length <= headLinesCount + tailLinesCount) {
|
|
31
|
+
// If few lines but very long strings, hard slice
|
|
32
|
+
const head = content.slice(0, Math.floor(maxChars * 0.3))
|
|
33
|
+
const tail = content.slice(-Math.floor(maxChars * 0.7))
|
|
34
|
+
const omittedChars = content.length - head.length - tail.length
|
|
35
|
+
return {
|
|
36
|
+
text: `${head}\n\n[... truncated ${omittedChars} characters to stay within context limits ...]\n\n${tail}`,
|
|
37
|
+
truncated: true,
|
|
38
|
+
omittedLines: 0,
|
|
39
|
+
omittedChars,
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const head = lines.slice(0, headLinesCount).join("\n")
|
|
44
|
+
const tail = lines.slice(-tailLinesCount).join("\n")
|
|
45
|
+
const omittedLines = lines.length - headLinesCount - tailLinesCount
|
|
46
|
+
const omittedChars = content.length - head.length - tail.length
|
|
47
|
+
|
|
48
|
+
const text = `${head}\n\n[... truncated ${omittedLines} lines (${omittedChars} chars) to stay within context limits ...]\n\n${tail}`
|
|
49
|
+
return {
|
|
50
|
+
text,
|
|
51
|
+
truncated: true,
|
|
52
|
+
omittedLines,
|
|
53
|
+
omittedChars,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface TruncatorOptions {
|
|
58
|
+
maxChars?: number
|
|
59
|
+
tools?: string[]
|
|
60
|
+
headLines?: number
|
|
61
|
+
tailLines?: number
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Truncator feature module: guards against sudden context window exhaustion
|
|
66
|
+
* by trimming high-volume tool outputs while keeping diagnostic head and tail lines.
|
|
67
|
+
*/
|
|
68
|
+
export const truncator: FeatureModule = {
|
|
69
|
+
name: "truncator",
|
|
70
|
+
tools: [],
|
|
71
|
+
defaultEnabled: true,
|
|
72
|
+
async init(_ctx, options, shared) {
|
|
73
|
+
const maxChars = typeof options.maxChars === "number" ? options.maxChars : DEFAULT_MAX_CHARS
|
|
74
|
+
const rawTools = Array.isArray(options.tools)
|
|
75
|
+
? (options.tools as string[])
|
|
76
|
+
: DEFAULT_TRUNCATABLE_TOOLS
|
|
77
|
+
const targetTools = new Set(
|
|
78
|
+
rawTools.flatMap((t) => {
|
|
79
|
+
const lower = t.toLowerCase()
|
|
80
|
+
const remapped = shared?.toolName ? shared.toolName(t).toLowerCase() : lower
|
|
81
|
+
return [lower, remapped]
|
|
82
|
+
}),
|
|
83
|
+
)
|
|
84
|
+
const headLines = typeof options.headLines === "number" ? options.headLines : DEFAULT_HEAD_LINES
|
|
85
|
+
const tailLines = typeof options.tailLines === "number" ? options.tailLines : DEFAULT_TAIL_LINES
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
"tool.execute.after": async (input, output) => {
|
|
89
|
+
if (!targetTools.has(input.tool.toLowerCase())) return
|
|
90
|
+
if (typeof output.output !== "string") return
|
|
91
|
+
|
|
92
|
+
const res = truncateOutput(output.output, maxChars, headLines, tailLines)
|
|
93
|
+
if (res.truncated) {
|
|
94
|
+
output.output = res.text
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
}
|