portable-agent-layer 0.114.0 → 0.115.0-canary.1
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 +2 -0
- package/assets/skills/timebox/SKILL.md +56 -0
- package/assets/skills/unattended/SKILL.md +84 -0
- package/assets/templates/settings.claude.json +1 -1
- package/package.json +1 -1
- package/src/cli/run-mode.ts +214 -0
- package/src/cli/tree.ts +3 -0
- package/src/hooks/CompactRecover.ts +15 -6
- package/src/hooks/StopOrchestrator.ts +19 -13
- package/src/hooks/handlers/inject-retrieval.ts +2 -0
- package/src/hooks/lib/adaptation-steering.ts +1 -1
- package/src/hooks/lib/claim-log.ts +1 -8
- package/src/hooks/lib/reply-checks.ts +24 -0
- package/src/hooks/lib/run-mode-gate.ts +130 -0
- package/src/hooks/lib/run-mode.ts +202 -0
- package/src/hooks/lib/security-gate.ts +32 -1
- package/src/hooks/lib/security.ts +16 -0
- package/src/hooks/lib/send-back-guard.ts +37 -0
- package/src/hooks/lib/session-events.ts +4 -0
- package/src/hooks/lib/wall-clock.ts +12 -0
package/README.md
CHANGED
|
@@ -95,6 +95,8 @@ pal cli status # check your setup
|
|
|
95
95
|
| `pal cli wrapped [--year <yyyy>]` | A year of your work in numbers, read from the event log |
|
|
96
96
|
| `pal cli patterns` | How you work across projects, read from the event log |
|
|
97
97
|
| `pal cli progress [show\|list\|open\|enter\|park\|wait\|done\|stopped\|decide\|attach\|follow-up]` | The progress tree: what you are on in this project, where it stopped, what is parked or waiting, and the decisions and artifacts on each node. Detours are child nodes and `done` returns to the parent. The active node rides along in every session and prompt, and your own commits attach to it at turn end |
|
|
98
|
+
| `pal cli timebox [start\|status\|end]` | Stop work at a deadline: `start --for 15m` or `--until 18:00`. After the deadline every working tool call is refused except `pal cli` and edits to the report, so the agent stops and reports what it has |
|
|
99
|
+
| `pal cli unattended [start\|log\|status\|done\|end]` | Work without you until a deadline: `start --until 05:30 --task … --fallback …`. Questions and subagents are refused, the agent cannot stop while approved tasks are left, and the rules, tasks and log come back after a compaction |
|
|
98
100
|
| `pal cli content preserve` | Copy every Claude Code transcript, gzipped, into `memory/content/` before Claude Code deletes it (30 days by default). Runs on its own whenever a session closes; the content store never leaves the machine, so `export` skips it |
|
|
99
101
|
| `pal cli events discord-nag <sent\|answered>` | For a bot outside PAL: record a nag it sent (`--kind <kind>`), or the answer to one and how long it took (`--after-sec <n>`), in the local event log |
|
|
100
102
|
| `pal cli infer` | One model call for scripts and bots, on the active agent's route: prompt on stdin, reply on stdout. `--tier small\|medium\|large\|flagship` (on Claude: Haiku, Sonnet, Opus, Fable; on Codex: Luna, Sol, Sol at high effort, Astra; on Antigravity: Flash at low, medium and high effort, and no flagship; an agent without the tier asked refuses it), `--system <file>`, `--schema <file>` for a JSON reply checked against it, `--timeout <seconds>`. The call loads no PAL hooks or context; exits 1 with the reason when no route answers |
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: timebox
|
|
3
|
+
license: MIT
|
|
4
|
+
description: "Works on a task under a user-given time limit and stops at the deadline with a report of what was found or done so far. Use when the request names a duration or a clock time for the work (\"stop in 15 minutes\", \"spend at most an hour\", \"stop at 6 PM\", \"timebox this\")."
|
|
5
|
+
argument-hint: <duration or HH:MM> <task>
|
|
6
|
+
metadata:
|
|
7
|
+
source: portable-agent-layer
|
|
8
|
+
triggers:
|
|
9
|
+
- "timebox"
|
|
10
|
+
- "time box"
|
|
11
|
+
- "stop in"
|
|
12
|
+
- "spend at most"
|
|
13
|
+
- "stop at"
|
|
14
|
+
- "time limit"
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
# Timebox
|
|
18
|
+
|
|
19
|
+
Work on $ARGUMENTS until the deadline the user set, then stop and report, wherever the work stands. PAL enforces the deadline for the current project or folder through its hooks; this skill is how to work inside it.
|
|
20
|
+
|
|
21
|
+
## Workflow
|
|
22
|
+
|
|
23
|
+
1. **Parse the limit** from the request. A duration ("15 minutes", "an hour and a half") becomes `--for 15m` or `--for 1h30m`; a clock time ("by 6 PM", "at 18:00") becomes `--until 18:00`, the next such local time. If no limit is clear, this is not the skill — do the task normally.
|
|
24
|
+
2. **Start the box** before any other work:
|
|
25
|
+
```bash
|
|
26
|
+
pal cli timebox start --for <15m|2h|1h30m> --task "<short task name>"
|
|
27
|
+
pal cli timebox start --until <HH:MM> --task "<short task name>"
|
|
28
|
+
```
|
|
29
|
+
Add `--report <path>` only when the user asked for the result in a file. The command fails if a box is already running for this folder; then ask whether to replace it (`pal cli timebox end`, then start) or work inside the one that exists.
|
|
30
|
+
3. **Work in small steps.** Run `pal cli timebox status` between steps; it prints the end time and the minutes left. Each user prompt during the box also carries "Time box until HH:MM, N min left". Plan the last step so it finishes before the deadline. Start nothing that cannot finish in the time left — a long build, a large download, a deep research pass — because a hook cannot interrupt a running command.
|
|
31
|
+
4. **Stop at the deadline.** After it, PAL's PreToolUse hook refuses shell, edit and write, web search and fetch, subagent and question calls with a message starting "Time box ended at HH:MM … Stop working now". Only `pal cli …` commands (optionally piped into `head` or `tail`) and writes to the `--report` path still run. Read-only tools may still answer, but do not keep working with them. Do not work around a refusal; move straight to the report.
|
|
32
|
+
5. **Report** in the final message, or in the report file when `--report` was given. Keep it short.
|
|
33
|
+
6. **End the box**: `pal cli timebox end`. A user prompt sent after the deadline clears it automatically; run `end` anyway so the state is explicit.
|
|
34
|
+
|
|
35
|
+
If the task finishes before the deadline, report then and run `pal cli timebox end` right away.
|
|
36
|
+
|
|
37
|
+
## Output format
|
|
38
|
+
|
|
39
|
+
The final message (or report file) has five parts, each one line or a short list:
|
|
40
|
+
|
|
41
|
+
- **Done / found** — the concrete results so far.
|
|
42
|
+
- **Not covered** — what the box cut off, or what was never reached.
|
|
43
|
+
- **Next** — the first steps to take if the work resumes, with the box ended.
|
|
44
|
+
- **Confidence** — a one-line judgment and its weakest point.
|
|
45
|
+
- **Box** — `started HH:MM, ended HH:MM` and whether the task finished early or was cut at the deadline.
|
|
46
|
+
|
|
47
|
+
## When to use
|
|
48
|
+
|
|
49
|
+
- The user gives a duration or clock time for the work: "research this but stop in 15 minutes", "spend at most an hour on the refactor", "stop at 6 PM", "timebox this to 30 minutes".
|
|
50
|
+
- The user says "timebox" or "time box" for any task.
|
|
51
|
+
|
|
52
|
+
## Do NOT use
|
|
53
|
+
|
|
54
|
+
- No time limit was given. Do the task normally; a time box without a user-stated limit is an invention.
|
|
55
|
+
- The user is leaving and nobody will answer questions — that is the `unattended` skill, which sets its own deadline and handles the absence.
|
|
56
|
+
- The user wants a reminder or an alarm with no work attached to it.
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: unattended
|
|
3
|
+
license: MIT
|
|
4
|
+
description: "Runs an approved list of tasks while the user is away — asleep, in a meeting, out for the afternoon — with nobody there to answer, then leaves a report for their return. Proposes finishable tasks and fallbacks, gets approval and a return time, starts the run with pal cli unattended, logs every decision, and finalises the report at the deadline. Use when the user says they are leaving and wants work to continue unattended, overnight, while they sleep, or until they are back."
|
|
5
|
+
argument-hint: "[return time] [tasks or goal]"
|
|
6
|
+
metadata:
|
|
7
|
+
source: portable-agent-layer
|
|
8
|
+
triggers:
|
|
9
|
+
- "unattended"
|
|
10
|
+
- "while I'm away"
|
|
11
|
+
- "work on this while I sleep"
|
|
12
|
+
- "overnight tasks"
|
|
13
|
+
- "night task"
|
|
14
|
+
- "until I'm back"
|
|
15
|
+
- "I'm going to bed"
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
# Unattended run
|
|
19
|
+
|
|
20
|
+
Work through approved tasks with nobody present to answer, then hand back a report. Three phases: propose, run, report. Do not start the run until the user has approved the plan.
|
|
21
|
+
|
|
22
|
+
## Phase A — Propose (before the user leaves)
|
|
23
|
+
|
|
24
|
+
1. Resolve the current project from the working folder (`pal cli project resume <slug>` when the user names one). Read its notes, open progress nodes and recent sessions: tasks come only from this project.
|
|
25
|
+
2. Draft candidate tasks. Keep a task only if it can be finished alone, with no decision that only the user can make. For each kept task write: what it produces, how it will be checked (tests or a build when the project has them, a visual comparison, sources cited, a reread against the brief), rough time, and resource cost (memory, network, paid APIs). Flag dropped tasks with the decision they need.
|
|
26
|
+
3. Propose 1-2 fallback tasks the run takes when the approved list is finished early: deterministic, low-risk work such as fixing lint warnings, adding missing tests, or updating documentation.
|
|
27
|
+
4. Ask, in one question with your agent's ask tool: the return time, where the report goes (a file path, a page the project serves, or an artifact), and extra rules. Suggest the rule categories: no paid or keyed APIs, no downloads, memory caps on heavy commands, files or services that are off-limits.
|
|
28
|
+
5. Repeat back in short form: tasks in order, fallbacks, return time, report location, extra rules on top of the defaults listed below. STOP and wait for explicit approval. Edits to the list return to step 5.
|
|
29
|
+
|
|
30
|
+
Default rules every run carries (`pal cli unattended start` stores them; `--rule` adds more):
|
|
31
|
+
- Never ask. Decide, log the decision and its reason, list the question in the report.
|
|
32
|
+
- Check notes and earlier sessions before listing a question.
|
|
33
|
+
- Approved tasks, then fallbacks, nothing else. Other ideas become suggestions in the report.
|
|
34
|
+
- Never delete, push, deploy or publish anything except the report.
|
|
35
|
+
- One heavy process at a time. No subagents, no workflows.
|
|
36
|
+
- Verify each step as if the user were present.
|
|
37
|
+
- Only in a git repository: work on a branch, one logical step per commit, stage only your own changes, never push.
|
|
38
|
+
|
|
39
|
+
## Phase B — Run (after approval)
|
|
40
|
+
|
|
41
|
+
1. Set the deadline about 30 minutes before the return time so the report is final when the user is back. Start the run:
|
|
42
|
+
```bash
|
|
43
|
+
pal cli unattended start --until <HH:MM> --task "<task 1>" --task "<task 2>" --fallback "<fallback>" --rule "<extra rule>" --report <path|url>
|
|
44
|
+
```
|
|
45
|
+
Then `pal cli progress open "<run title>"` for the run's progress node.
|
|
46
|
+
2. In a git repository, create the work branch. Take tasks in the approved order, one at a time.
|
|
47
|
+
3. For each task: do it; verify it the way Phase A agreed (when the project has tests, run them twice and break-check every new test; when it has a build, run it); in a git repository, commit. Then `pal cli unattended log "<result> and how it was verified"` and `pal cli progress stopped "<next step>"`. Commits attach to the progress node on their own; do not attach them by hand.
|
|
48
|
+
4. Log every non-obvious decision with its reason at the moment of deciding: `pal cli unattended log "<decision>: <reason>"`.
|
|
49
|
+
5. Run `pal cli unattended status` between steps. Compare the minutes left with the estimated length of the next step; never start a step that cannot finish before the deadline, because a hook cannot interrupt a running command. Pick a shorter step or move to the report instead.
|
|
50
|
+
6. Write the report to the agreed location as soon as the first task lands, with "(draft)" in its title. Update it after every task so a crash leaves a usable report behind.
|
|
51
|
+
7. When the approved tasks are finished, take the fallbacks in order. The Stop hook refuses to end the turn before `pal cli unattended done`, so finishing early means moving on, not idling.
|
|
52
|
+
8. Treat every refused tool call as a rule: questions to the user and subagent or workflow calls are refused during the run, and after the deadline every working tool is refused except `pal cli` and writes to the report path. The refusal depends on the agent's tool names being known to PAL, so never ask or delegate even when no hook stops you. Never work around a refusal; log it and continue with the next permitted step.
|
|
53
|
+
9. After a context compaction, run `pal cli unattended status` and resume from the last logged step. Agents with a compaction hook also get the run's rules, tasks and log re-injected; re-read them first.
|
|
54
|
+
|
|
55
|
+
## Phase C — Report (at the deadline)
|
|
56
|
+
|
|
57
|
+
1. Finalise the report, removing "(draft)". Sections, in order:
|
|
58
|
+
- **Done** — each task, how it was verified, and its commits, test counts and build result where the project has them.
|
|
59
|
+
- **Comparisons** — for visual work, reference next to result, with an honest "falls short" note where it does.
|
|
60
|
+
- **Failed, skipped, reverted** — what, why, and what was reverted.
|
|
61
|
+
- **Questions for the user** — each answerable in one line; only questions the notes could not answer.
|
|
62
|
+
- **Unfinished work** — files left mid-change and what state they are in; in a git repository, the branch and its uncommitted files.
|
|
63
|
+
Label every inference of your own as such ("my reading:", "unverified:").
|
|
64
|
+
2. If the report is served somewhere, open the served copy (the `playwright` skill, or fetch the URL) and confirm it matches the local file.
|
|
65
|
+
3. `pal cli progress done`, then `pal cli unattended done`.
|
|
66
|
+
4. When the user returns, reply first with a summary under 100 words: what landed, the report location, and the open questions. Then run `pal cli unattended end`.
|
|
67
|
+
|
|
68
|
+
## Output format
|
|
69
|
+
|
|
70
|
+
- Phase A ends with the short-form plan (tasks, fallbacks, return time, report location, extra rules) followed by one approval question. Nothing runs before the answer.
|
|
71
|
+
- Phase B produces the task results (commits on the branch in a git repository), `pal cli unattended log` entries, progress-tree updates, and the draft report at the agreed location.
|
|
72
|
+
- Phase C produces the final report with the five sections above and, on the user's return, a summary under 100 words naming the report location and the open questions.
|
|
73
|
+
|
|
74
|
+
## When to use
|
|
75
|
+
|
|
76
|
+
- The user is leaving (sleep, meeting, travel, the rest of the day) and wants work to continue with nobody there to answer.
|
|
77
|
+
- The user asks for an overnight or night task, or wants the work to keep going while they are away or until they are back.
|
|
78
|
+
- A list of self-contained tasks exists, or can be drawn from the current project, that needs no user decision mid-way.
|
|
79
|
+
|
|
80
|
+
## Do NOT use
|
|
81
|
+
|
|
82
|
+
- The user stays at the keyboard and only wants a time limit on the work: that is the `timebox` skill.
|
|
83
|
+
- A single long-running command that should run in the background and be checked later.
|
|
84
|
+
- Tasks that hinge on a decision only the user can make: propose them, flag the decision, and leave them out of the run.
|
package/package.json
CHANGED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pal cli timebox / pal cli unattended — start, inspect and end a run with a
|
|
3
|
+
* deadline for the current project or folder. The hooks enforce it.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { scopeOf } from "../hooks/lib/progress-tree";
|
|
7
|
+
import {
|
|
8
|
+
clearRun,
|
|
9
|
+
deadlineFrom,
|
|
10
|
+
describeRun,
|
|
11
|
+
newRun,
|
|
12
|
+
type Run,
|
|
13
|
+
type RunKind,
|
|
14
|
+
readRun,
|
|
15
|
+
unattendedRules,
|
|
16
|
+
untilLabel,
|
|
17
|
+
writeRun,
|
|
18
|
+
} from "../hooks/lib/run-mode";
|
|
19
|
+
import { isInGitRepo } from "../hooks/lib/session-events";
|
|
20
|
+
import { group, leaf, UsageError } from "../tools/lib/command";
|
|
21
|
+
|
|
22
|
+
const FOR = {
|
|
23
|
+
type: "string",
|
|
24
|
+
value: "<duration>",
|
|
25
|
+
description: "How long, e.g. 15m, 2h or 1h30m",
|
|
26
|
+
} as const;
|
|
27
|
+
const UNTIL = {
|
|
28
|
+
type: "string",
|
|
29
|
+
value: "<HH:MM>",
|
|
30
|
+
description: "Local time it ends (the next one), or an ISO time",
|
|
31
|
+
} as const;
|
|
32
|
+
const REPORT = {
|
|
33
|
+
type: "string",
|
|
34
|
+
value: "<path|url>",
|
|
35
|
+
description:
|
|
36
|
+
"Where the report goes; edits to a local path still run after the deadline",
|
|
37
|
+
} as const;
|
|
38
|
+
|
|
39
|
+
function currentScope(): string {
|
|
40
|
+
return scopeOf(process.cwd());
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function deadline(values: { for?: string; until?: string }): Date {
|
|
44
|
+
try {
|
|
45
|
+
return deadlineFrom(values);
|
|
46
|
+
} catch (err) {
|
|
47
|
+
throw new UsageError((err as Error).message);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function runOf(kind: RunKind): Run | null {
|
|
52
|
+
const run = readRun(currentScope());
|
|
53
|
+
return run?.kind === kind ? run : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function noRun(kind: RunKind): number {
|
|
57
|
+
console.error(`No ${kind} run in ${currentScope()}.`);
|
|
58
|
+
return 1;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function refuseIfRunning(): void {
|
|
62
|
+
const run = readRun(currentScope());
|
|
63
|
+
if (run)
|
|
64
|
+
throw new UsageError(
|
|
65
|
+
`A ${run.kind} run until ${untilLabel(run)} is already on here; end it first`
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function printStatus(kind: RunKind): number {
|
|
70
|
+
const run = runOf(kind);
|
|
71
|
+
if (!run) return noRun(kind);
|
|
72
|
+
console.log(describeRun(run));
|
|
73
|
+
return 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function endRun(kind: RunKind): number {
|
|
77
|
+
const run = runOf(kind);
|
|
78
|
+
if (!run) return noRun(kind);
|
|
79
|
+
clearRun(run.scope);
|
|
80
|
+
console.log(`Ended the ${kind} run that was due at ${untilLabel(run)}.`);
|
|
81
|
+
return 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function startTimebox(values: {
|
|
85
|
+
for?: string;
|
|
86
|
+
until?: string;
|
|
87
|
+
task?: string;
|
|
88
|
+
report?: string;
|
|
89
|
+
}): number {
|
|
90
|
+
refuseIfRunning();
|
|
91
|
+
const run = newRun({
|
|
92
|
+
kind: "timebox",
|
|
93
|
+
scope: currentScope(),
|
|
94
|
+
until: deadline(values),
|
|
95
|
+
task: values.task,
|
|
96
|
+
report: values.report,
|
|
97
|
+
});
|
|
98
|
+
writeRun(run);
|
|
99
|
+
console.log(
|
|
100
|
+
`Time box set: ends at ${untilLabel(run)}. After that only \`pal cli\` and report edits run.`
|
|
101
|
+
);
|
|
102
|
+
return 0;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function startUnattended(values: {
|
|
106
|
+
until?: string;
|
|
107
|
+
for?: string;
|
|
108
|
+
task?: string[];
|
|
109
|
+
fallback?: string[];
|
|
110
|
+
rule?: string[];
|
|
111
|
+
report?: string;
|
|
112
|
+
}): number {
|
|
113
|
+
refuseIfRunning();
|
|
114
|
+
if (!values.task?.length) throw new UsageError("give each approved task with --task");
|
|
115
|
+
const run = newRun({
|
|
116
|
+
kind: "unattended",
|
|
117
|
+
scope: currentScope(),
|
|
118
|
+
until: deadline(values),
|
|
119
|
+
report: values.report,
|
|
120
|
+
tasks: values.task,
|
|
121
|
+
fallbacks: values.fallback ?? [],
|
|
122
|
+
rules: unattendedRules(isInGitRepo(process.cwd()), values.rule),
|
|
123
|
+
});
|
|
124
|
+
writeRun(run);
|
|
125
|
+
console.log(describeRun(run));
|
|
126
|
+
return 0;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function logEntry(text: string): number {
|
|
130
|
+
const run = runOf("unattended");
|
|
131
|
+
if (!run) return noRun("unattended");
|
|
132
|
+
writeRun({ ...run, log: [...run.log, { at: new Date().toISOString(), entry: text }] });
|
|
133
|
+
console.log(`Logged (${run.log.length + 1} entries).`);
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function markDone(): number {
|
|
138
|
+
const run = runOf("unattended");
|
|
139
|
+
if (!run) return noRun("unattended");
|
|
140
|
+
writeRun({ ...run, done: true });
|
|
141
|
+
console.log("Marked done: the agent may stop. The run ends when the user is back.");
|
|
142
|
+
return 0;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export const timeboxCommand = group({
|
|
146
|
+
summary: "Stop work at a deadline and report what was found",
|
|
147
|
+
commands: {
|
|
148
|
+
start: leaf({
|
|
149
|
+
summary: "Start a time box for the current project or folder",
|
|
150
|
+
options: {
|
|
151
|
+
for: FOR,
|
|
152
|
+
until: UNTIL,
|
|
153
|
+
task: { type: "string", value: "<text>", description: "What is being done" },
|
|
154
|
+
report: REPORT,
|
|
155
|
+
},
|
|
156
|
+
run: ({ values }) => startTimebox(values),
|
|
157
|
+
}),
|
|
158
|
+
status: leaf({
|
|
159
|
+
summary: "Show the time box and the time left",
|
|
160
|
+
run: () => printStatus("timebox"),
|
|
161
|
+
}),
|
|
162
|
+
end: leaf({ summary: "End the time box", run: () => endRun("timebox") }),
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
export const unattendedCommand = group({
|
|
167
|
+
summary: "Work without the user until a deadline: no questions, approved tasks only",
|
|
168
|
+
commands: {
|
|
169
|
+
start: leaf({
|
|
170
|
+
summary: "Start an unattended run with the approved tasks",
|
|
171
|
+
options: {
|
|
172
|
+
until: UNTIL,
|
|
173
|
+
for: FOR,
|
|
174
|
+
task: {
|
|
175
|
+
type: "string",
|
|
176
|
+
multiple: true,
|
|
177
|
+
value: "<text>",
|
|
178
|
+
description: "An approved task, in order (repeat)",
|
|
179
|
+
},
|
|
180
|
+
fallback: {
|
|
181
|
+
type: "string",
|
|
182
|
+
multiple: true,
|
|
183
|
+
value: "<text>",
|
|
184
|
+
description: "A pre-approved task for when the tasks run out (repeat)",
|
|
185
|
+
},
|
|
186
|
+
rule: {
|
|
187
|
+
type: "string",
|
|
188
|
+
multiple: true,
|
|
189
|
+
value: "<text>",
|
|
190
|
+
description: "A rule on top of the defaults (repeat)",
|
|
191
|
+
},
|
|
192
|
+
report: REPORT,
|
|
193
|
+
},
|
|
194
|
+
run: ({ values }) => startUnattended(values),
|
|
195
|
+
}),
|
|
196
|
+
log: leaf({
|
|
197
|
+
summary: "Append a timestamped entry to the run's log",
|
|
198
|
+
args: "<entry...>",
|
|
199
|
+
run: ({ positionals }) => logEntry(positionals.join(" ")),
|
|
200
|
+
}),
|
|
201
|
+
status: leaf({
|
|
202
|
+
summary: "Show the rules, tasks, log and time left",
|
|
203
|
+
run: () => printStatus("unattended"),
|
|
204
|
+
}),
|
|
205
|
+
done: leaf({
|
|
206
|
+
summary: "Every task and fallback is finished and the report is written",
|
|
207
|
+
run: markDone,
|
|
208
|
+
}),
|
|
209
|
+
end: leaf({
|
|
210
|
+
summary: "End the run (the user is back)",
|
|
211
|
+
run: () => endRun("unattended"),
|
|
212
|
+
}),
|
|
213
|
+
},
|
|
214
|
+
});
|
package/src/cli/tree.ts
CHANGED
|
@@ -27,6 +27,7 @@ import { patternsCommand } from "./patterns";
|
|
|
27
27
|
import { telosCommand, timezoneCommand } from "./personal-context";
|
|
28
28
|
import { progressCommand } from "./progress";
|
|
29
29
|
import { ruleCommand } from "./rule";
|
|
30
|
+
import { timeboxCommand, unattendedCommand } from "./run-mode";
|
|
30
31
|
import { serverCommand } from "./server";
|
|
31
32
|
import { skillCommand } from "./skill";
|
|
32
33
|
import { subagentCommand } from "./subagent";
|
|
@@ -187,6 +188,8 @@ export function cliTree(admin: AdminHandlers): Group {
|
|
|
187
188
|
rule: ruleCommand,
|
|
188
189
|
correction: correctionCommand,
|
|
189
190
|
progress: progressCommand,
|
|
191
|
+
timebox: timeboxCommand,
|
|
192
|
+
unattended: unattendedCommand,
|
|
190
193
|
server: serverCommand,
|
|
191
194
|
skill: skillCommand,
|
|
192
195
|
subagent: subagentCommand,
|
|
@@ -15,7 +15,9 @@ import {
|
|
|
15
15
|
type SavedExchange,
|
|
16
16
|
} from "./lib/compact-recall";
|
|
17
17
|
import { duplicatesCursorHooks } from "./lib/cursor-shadow";
|
|
18
|
+
import { enterHookWorkspace } from "./lib/hook-turn";
|
|
18
19
|
import { logDebug, logError } from "./lib/log";
|
|
20
|
+
import { runModeRecall } from "./lib/run-mode-gate";
|
|
19
21
|
import { isPalSpawnedInference } from "./lib/spawn-guard";
|
|
20
22
|
import { readStdinJSON } from "./lib/stdin";
|
|
21
23
|
|
|
@@ -38,19 +40,26 @@ try {
|
|
|
38
40
|
process.exit(0);
|
|
39
41
|
}
|
|
40
42
|
|
|
43
|
+
enterHookWorkspace(input);
|
|
41
44
|
const sessionId = input?.session_id;
|
|
45
|
+
const run = runModeRecall(process.cwd());
|
|
42
46
|
const file = findSavedExchange(sessionId);
|
|
43
|
-
if (!file) {
|
|
44
|
-
logDebug("CompactRecover", "No saved exchange found — silent no-op");
|
|
47
|
+
if (!file && !run) {
|
|
48
|
+
logDebug("CompactRecover", "No saved exchange or run found — silent no-op");
|
|
45
49
|
process.exit(0);
|
|
46
50
|
}
|
|
47
51
|
|
|
48
|
-
const
|
|
49
|
-
|
|
52
|
+
const exchange = file
|
|
53
|
+
? buildRecall(JSON.parse(await readFile(file, "utf-8")) as SavedExchange)
|
|
54
|
+
: "";
|
|
55
|
+
const out = [run, exchange].filter(Boolean).join("\n\n");
|
|
50
56
|
process.stdout.write(isCursor() ? JSON.stringify({ additional_context: out }) : out);
|
|
51
|
-
logDebug(
|
|
57
|
+
logDebug(
|
|
58
|
+
"CompactRecover",
|
|
59
|
+
`Re-injected ${out.length} chars from ${file ?? "run mode"}`
|
|
60
|
+
);
|
|
52
61
|
|
|
53
|
-
if (isConsumable(file, sessionId)) await unlink(file);
|
|
62
|
+
if (file && isConsumable(file, sessionId)) await unlink(file);
|
|
54
63
|
} catch (err) {
|
|
55
64
|
logError("CompactRecover", err);
|
|
56
65
|
}
|
|
@@ -6,18 +6,18 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { checkReadmeSync } from "./handlers/readme-sync";
|
|
9
|
-
import { watchReplyRules } from "./lib/adaptation-steering";
|
|
10
9
|
import { stopBlockResponse } from "./lib/agent";
|
|
11
10
|
import {
|
|
12
11
|
type AntigravityStopFields,
|
|
13
12
|
isSideStop,
|
|
14
13
|
withTranscriptReply,
|
|
15
14
|
} from "./lib/antigravity-transcript";
|
|
16
|
-
import { watchClaims } from "./lib/claim-log";
|
|
17
15
|
import { duplicatesCursorHooks } from "./lib/cursor-shadow";
|
|
18
16
|
import { enterHookWorkspace } from "./lib/hook-turn";
|
|
19
17
|
import { logError } from "./lib/log";
|
|
20
18
|
import { stopProgressTree } from "./lib/progress-tree-stop";
|
|
19
|
+
import { replySendBack } from "./lib/reply-checks";
|
|
20
|
+
import { unattendedStopReason } from "./lib/run-mode-gate";
|
|
21
21
|
import { isPalSpawnedInference } from "./lib/spawn-guard";
|
|
22
22
|
import { readStdinJSON } from "./lib/stdin";
|
|
23
23
|
import { type StopTurnPayload, stopTurn } from "./lib/stop";
|
|
@@ -30,6 +30,14 @@ if (isSideStop(received)) process.exit(0);
|
|
|
30
30
|
const payload = withTranscriptReply(received);
|
|
31
31
|
enterHookWorkspace(payload);
|
|
32
32
|
|
|
33
|
+
// First: it reads only git and PRs, so every stop attempt attaches new commits, even one a block below refuses.
|
|
34
|
+
try {
|
|
35
|
+
stopProgressTree(payload);
|
|
36
|
+
} catch (err) {
|
|
37
|
+
logError("StopOrchestrator:progress-tree", err);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Fix requests come before the keep-working block; the README first, as it blocks every attempt until fixed.
|
|
33
41
|
try {
|
|
34
42
|
// A block carrying no reason stops the turn without telling the model why, so it
|
|
35
43
|
// is worth less than not blocking at all — require the reason to raise one.
|
|
@@ -42,27 +50,25 @@ try {
|
|
|
42
50
|
logError("StopOrchestrator:readme-sync", err);
|
|
43
51
|
}
|
|
44
52
|
|
|
53
|
+
// Reply checks next, claims before style rules: one send-back per reply, so the corrected reply goes through.
|
|
45
54
|
try {
|
|
46
|
-
const sendBack =
|
|
55
|
+
const sendBack = replySendBack(payload);
|
|
47
56
|
if (sendBack) {
|
|
48
57
|
process.stdout.write(stopBlockResponse(sendBack));
|
|
49
58
|
process.exit(0);
|
|
50
59
|
}
|
|
51
60
|
} catch (err) {
|
|
52
|
-
logError("StopOrchestrator:
|
|
61
|
+
logError("StopOrchestrator:reply-checks", err);
|
|
53
62
|
}
|
|
63
|
+
// After the reply checks: a reply that needs fixing goes back for the fix, not for the next task.
|
|
54
64
|
try {
|
|
55
|
-
const
|
|
56
|
-
if (
|
|
57
|
-
process.stdout.write(stopBlockResponse(
|
|
65
|
+
const keepGoing = unattendedStopReason(process.cwd());
|
|
66
|
+
if (keepGoing) {
|
|
67
|
+
process.stdout.write(stopBlockResponse(keepGoing));
|
|
58
68
|
process.exit(0);
|
|
59
69
|
}
|
|
60
70
|
} catch (err) {
|
|
61
|
-
logError("StopOrchestrator:
|
|
62
|
-
}
|
|
63
|
-
try {
|
|
64
|
-
stopProgressTree(payload);
|
|
65
|
-
} catch (err) {
|
|
66
|
-
logError("StopOrchestrator:progress-tree", err);
|
|
71
|
+
logError("StopOrchestrator:unattended", err);
|
|
67
72
|
}
|
|
73
|
+
// Last: it writes to the terminal and starts inference, which only a stop that really ends may do.
|
|
68
74
|
await stopTurn(payload);
|
|
@@ -16,6 +16,7 @@ import { parkPromptContext } from "../lib/parked-context";
|
|
|
16
16
|
import { progressTreePromptLine } from "../lib/progress-tree-context";
|
|
17
17
|
import { isSystemText } from "../lib/prompt-text";
|
|
18
18
|
import { getRepoStateReminder } from "../lib/repo-state";
|
|
19
|
+
import { runModePromptLine } from "../lib/run-mode-gate";
|
|
19
20
|
import { isEnabled } from "../lib/settings";
|
|
20
21
|
import { getSkillReminder } from "../lib/skill-match";
|
|
21
22
|
import { getSteeringReminder } from "../lib/steering";
|
|
@@ -62,6 +63,7 @@ export async function getPromptContext(
|
|
|
62
63
|
const userText = isSystemText(prompt) ? "" : prompt;
|
|
63
64
|
const parts = [
|
|
64
65
|
getWallClockReminder(),
|
|
66
|
+
withinBudget(() => runModePromptLine(process.cwd()), BUDGET_MS),
|
|
65
67
|
userText && withinBudget(() => promptRulesReminder(userText, sessionId), BUDGET_MS),
|
|
66
68
|
withinBudget(() => getRepoStateReminder(), BUDGET_MS),
|
|
67
69
|
withinBudget(() => progressTreePromptLine(userText, undefined, sessionId), BUDGET_MS),
|
|
@@ -11,10 +11,10 @@ import {
|
|
|
11
11
|
type RuleTrigger,
|
|
12
12
|
readRules,
|
|
13
13
|
} from "./adaptation-rules";
|
|
14
|
-
import { alreadySentBack } from "./claim-log";
|
|
15
14
|
import { hookFinalReply, hookSessionId } from "./hook-turn";
|
|
16
15
|
import { replyEnd } from "./interaction-samples";
|
|
17
16
|
import { ruleEventsPath } from "./rule-effect";
|
|
17
|
+
import { alreadySentBack } from "./send-back-guard";
|
|
18
18
|
import { isEnabled } from "./settings";
|
|
19
19
|
import type { StopTurnPayload } from "./stop";
|
|
20
20
|
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
} from "./claim-check";
|
|
19
19
|
import { hookFinalReply, hookSessionId } from "./hook-turn";
|
|
20
20
|
import { ensureDir, paths } from "./paths";
|
|
21
|
+
import { alreadySentBack } from "./send-back-guard";
|
|
21
22
|
import { isEnabled, isOptedIn } from "./settings";
|
|
22
23
|
import type { StopTurnPayload } from "./stop";
|
|
23
24
|
import { readTranscriptTail } from "./transcript";
|
|
@@ -81,14 +82,6 @@ function record({ check, commands, session, blocked }: Watched, now: Date) {
|
|
|
81
82
|
appendFileSync(monthFile(now), `${JSON.stringify(line)}\n`);
|
|
82
83
|
}
|
|
83
84
|
|
|
84
|
-
export function alreadySentBack(payload: StopTurnPayload): boolean {
|
|
85
|
-
return (
|
|
86
|
-
payload.stop_hook_active === true ||
|
|
87
|
-
(payload.loop_count ?? 0) > 0 ||
|
|
88
|
-
(payload.executionNum ?? 0) > 0
|
|
89
|
-
);
|
|
90
|
-
}
|
|
91
|
-
|
|
92
85
|
function sendsBack(payload: StopTurnPayload, check: ClaimCheck): boolean {
|
|
93
86
|
return (
|
|
94
87
|
check.verdict === "unbacked" &&
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { watchReplyRules } from "./adaptation-steering";
|
|
2
|
+
import { watchClaims } from "./claim-log";
|
|
3
|
+
import { logError } from "./log";
|
|
4
|
+
import { rememberSendBack } from "./send-back-guard";
|
|
5
|
+
import type { StopTurnPayload } from "./stop";
|
|
6
|
+
|
|
7
|
+
function skippedOnError(label: string, check: () => string | null): string | null {
|
|
8
|
+
try {
|
|
9
|
+
return check();
|
|
10
|
+
} catch (err) {
|
|
11
|
+
logError(`StopOrchestrator:${label}`, err);
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The claim check, then the approved reply rules; the first reason to send the reply back wins. */
|
|
17
|
+
export function replySendBack(payload: StopTurnPayload | null): string | null {
|
|
18
|
+
if (!payload) return null;
|
|
19
|
+
const reason =
|
|
20
|
+
skippedOnError("claim-check", () => watchClaims(payload)) ??
|
|
21
|
+
skippedOnError("adaptation-rules", () => watchReplyRules(payload));
|
|
22
|
+
rememberSendBack(payload, reason !== null);
|
|
23
|
+
return reason;
|
|
24
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the hooks do while a run is on: refuse tool calls past the deadline,
|
|
3
|
+
* refuse questions and delegation while unattended, keep an unattended agent
|
|
4
|
+
* from stopping with work left, and say where the run stands.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { resolve } from "node:path";
|
|
8
|
+
import { scopeOf } from "./progress-tree";
|
|
9
|
+
import {
|
|
10
|
+
clearRun,
|
|
11
|
+
describeRun,
|
|
12
|
+
isOver,
|
|
13
|
+
minutesLeft,
|
|
14
|
+
type Run,
|
|
15
|
+
readRun,
|
|
16
|
+
untilLabel,
|
|
17
|
+
writeRun,
|
|
18
|
+
} from "./run-mode";
|
|
19
|
+
import { runsOnlyPalCli } from "./security";
|
|
20
|
+
|
|
21
|
+
export interface RunToolCall {
|
|
22
|
+
tool: string;
|
|
23
|
+
command?: string;
|
|
24
|
+
filePath?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const ASK_TOOLS = new Set([
|
|
28
|
+
"askuserquestion",
|
|
29
|
+
"ask_user",
|
|
30
|
+
"askuser",
|
|
31
|
+
"ask_followup_question",
|
|
32
|
+
"request_user_input",
|
|
33
|
+
]);
|
|
34
|
+
const DELEGATE_TOOLS = new Set(["agent", "task", "workflow", "spawn_agent"]);
|
|
35
|
+
export const MAX_STOP_BLOCKS = 20;
|
|
36
|
+
|
|
37
|
+
function runIn(cwd: string, now: Date): Run | null {
|
|
38
|
+
return readRun(scopeOf(cwd), now);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function writesTheReport(run: Run, call: RunToolCall, cwd: string): boolean {
|
|
42
|
+
if (!run.report || !call.filePath) return false;
|
|
43
|
+
return resolve(cwd, call.filePath) === resolve(cwd, run.report);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function finishesTheRun(run: Run, call: RunToolCall, cwd: string): boolean {
|
|
47
|
+
return (
|
|
48
|
+
(call.command !== undefined && runsOnlyPalCli(call.command)) ||
|
|
49
|
+
writesTheReport(run, call, cwd)
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function closeCommand(run: Run): string {
|
|
54
|
+
return run.kind === "unattended" ? "pal cli unattended done" : "pal cli timebox end";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function deadlineMessage(run: Run): string {
|
|
58
|
+
const task = run.task ? ` (${run.task})` : "";
|
|
59
|
+
const report = run.report ? ` Finish the report at ${run.report}.` : "";
|
|
60
|
+
return `Time box ended at ${untilLabel(run)}${task}. Stop working now: report what you found, what is unfinished and what comes next.${report} Only \`pal cli\` and edits to the report still run. Then run \`${closeCommand(run)}\`.`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function unattendedRefusal(tool: string): string | null {
|
|
64
|
+
const name = tool.toLowerCase();
|
|
65
|
+
if (ASK_TOOLS.has(name))
|
|
66
|
+
return "Unattended run: nobody can answer. Decide, log it with `pal cli unattended log`, and put the question in the report.";
|
|
67
|
+
if (DELEGATE_TOOLS.has(name))
|
|
68
|
+
return "Unattended run: one process at a time, so no subagents or workflows. Do the step yourself.";
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Why this call may not run under the scope's run, or null to let it through. */
|
|
73
|
+
export function runModeRefusal(
|
|
74
|
+
call: RunToolCall,
|
|
75
|
+
cwd: string,
|
|
76
|
+
now: Date = new Date()
|
|
77
|
+
): string | null {
|
|
78
|
+
const run = runIn(cwd, now);
|
|
79
|
+
if (!run) return null;
|
|
80
|
+
if (isOver(run, now))
|
|
81
|
+
return finishesTheRun(run, call, cwd) ? null : deadlineMessage(run);
|
|
82
|
+
return run.kind === "unattended" ? unattendedRefusal(call.tool) : null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function reportPlace(run: Run): string {
|
|
86
|
+
return run.report ? ` at ${run.report}` : "";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function stopMessage(run: Run, now: Date): string {
|
|
90
|
+
if (isOver(run, now))
|
|
91
|
+
return `Unattended run ended at ${untilLabel(run)}. Finalise the report${reportPlace(run)}, then run \`pal cli unattended done\`.`;
|
|
92
|
+
return `Unattended until ${untilLabel(run)} (${minutesLeft(run, now)} min left): the user is not here to take a reply. Log the step you finished, then take the next approved task, or a fallback when none is left. Keep the draft report current. When every task and fallback is finished and the report is written, run \`pal cli unattended done\`.`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The reason to keep an unattended agent going, or null to let it stop. Counts each block. */
|
|
96
|
+
export function unattendedStopReason(cwd: string, now: Date = new Date()): string | null {
|
|
97
|
+
const run = runIn(cwd, now);
|
|
98
|
+
if (!run || run.kind !== "unattended" || run.done) return null;
|
|
99
|
+
if (run.stopBlocks >= MAX_STOP_BLOCKS) return null;
|
|
100
|
+
writeRun({ ...run, stopBlocks: run.stopBlocks + 1 });
|
|
101
|
+
return stopMessage(run, now);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function taskSuffix(run: Run): string {
|
|
105
|
+
return run.task ? `: ${run.task}` : "";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function promptLine(run: Run, now: Date): string {
|
|
109
|
+
if (run.kind === "timebox")
|
|
110
|
+
return `Time box until ${untilLabel(run)}, ${minutesLeft(run, now)} min left${taskSuffix(run)}. Stop and report at the deadline.`;
|
|
111
|
+
return `Unattended run until ${untilLabel(run)}, but the user just wrote: answer them. If they are back, run \`pal cli unattended end\`.`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The per-prompt line for the scope's run. A user prompt after the deadline means the run is over, so it is cleared. */
|
|
115
|
+
export function runModePromptLine(cwd: string, now: Date = new Date()): string | null {
|
|
116
|
+
const run = runIn(cwd, now);
|
|
117
|
+
if (!run) return null;
|
|
118
|
+
if (isOver(run, now)) {
|
|
119
|
+
clearRun(run.scope);
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
return `<system-reminder>${promptLine(run, now)}</system-reminder>`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The run in full, for the context restored after a compaction. */
|
|
126
|
+
export function runModeRecall(cwd: string, now: Date = new Date()): string | null {
|
|
127
|
+
const run = runIn(cwd, now);
|
|
128
|
+
if (!run) return null;
|
|
129
|
+
return `<system-reminder>\n## Active run — re-read before doing anything else\n${describeRun(run, now)}\n</system-reminder>`;
|
|
130
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A run with a deadline, kept per scope (a registered project, or the folder).
|
|
3
|
+
* A timebox ends work at the deadline; an unattended run also means nobody is
|
|
4
|
+
* there to answer until then. The hooks read this file; `pal cli timebox` and
|
|
5
|
+
* `pal cli unattended` write it.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { resolve } from "node:path";
|
|
11
|
+
import { ensureDir, paths } from "./paths";
|
|
12
|
+
import { localHourMinute, localMinuteOfDay } from "./wall-clock";
|
|
13
|
+
|
|
14
|
+
export type RunKind = "timebox" | "unattended";
|
|
15
|
+
|
|
16
|
+
interface RunLogEntry {
|
|
17
|
+
at: string;
|
|
18
|
+
entry: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface Run {
|
|
22
|
+
kind: RunKind;
|
|
23
|
+
scope: string;
|
|
24
|
+
startedAt: string;
|
|
25
|
+
until: string;
|
|
26
|
+
task: string | null;
|
|
27
|
+
report: string | null;
|
|
28
|
+
tasks: string[];
|
|
29
|
+
fallbacks: string[];
|
|
30
|
+
rules: string[];
|
|
31
|
+
log: RunLogEntry[];
|
|
32
|
+
done: boolean;
|
|
33
|
+
stopBlocks: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const MINUTE_MS = 60_000;
|
|
37
|
+
const DAY_MINUTES = 1440;
|
|
38
|
+
const FORGOTTEN_AFTER_MS = 12 * 60 * MINUTE_MS;
|
|
39
|
+
|
|
40
|
+
export const DEFAULT_UNATTENDED_RULES = [
|
|
41
|
+
"Nobody can answer until the deadline: never ask. Decide, log the decision and why with `pal cli unattended log`, and list open questions in the report.",
|
|
42
|
+
"Before listing a question, search the project's notes and earlier sessions for an answer.",
|
|
43
|
+
"Do only the approved tasks, then the fallbacks. Anything else goes in the report as a suggestion.",
|
|
44
|
+
"Never delete, push, deploy or publish anything other than the report.",
|
|
45
|
+
"One heavy process at a time. No subagents or workflows.",
|
|
46
|
+
"Verify each step as you would with the user present, and log it when it lands.",
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
export const GIT_RULE =
|
|
50
|
+
"Work on a branch, one logical step per commit, staging only your own changes. Never push.";
|
|
51
|
+
|
|
52
|
+
/** The default rules, plus the commit rule when the work happens in a git repository. */
|
|
53
|
+
export function unattendedRules(inGitRepo: boolean, extra: string[] = []): string[] {
|
|
54
|
+
return [...DEFAULT_UNATTENDED_RULES, ...(inGitRepo ? [GIT_RULE] : []), ...extra];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function runDir(): string {
|
|
58
|
+
return ensureDir(resolve(paths.state(), "run-mode"));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function runFile(scope: string): string {
|
|
62
|
+
const key = createHash("sha1").update(scope).digest("hex").slice(0, 16);
|
|
63
|
+
return resolve(runDir(), `${key}.json`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function parseRun(file: string): Run | null {
|
|
67
|
+
try {
|
|
68
|
+
return JSON.parse(readFileSync(file, "utf-8")) as Run;
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isForgotten(run: Run, now: Date): boolean {
|
|
75
|
+
return now.getTime() - Date.parse(run.until) > FORGOTTEN_AFTER_MS;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The scope's run, or null when there is none or it ended long enough ago to be forgotten. */
|
|
79
|
+
export function readRun(scope: string, now: Date = new Date()): Run | null {
|
|
80
|
+
const file = runFile(scope);
|
|
81
|
+
if (!existsSync(file)) return null;
|
|
82
|
+
const run = parseRun(file);
|
|
83
|
+
return run && !isForgotten(run, now) ? run : null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function writeRun(run: Run): void {
|
|
87
|
+
writeFileSync(runFile(run.scope), `${JSON.stringify(run, null, 2)}\n`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function clearRun(scope: string): boolean {
|
|
91
|
+
const file = runFile(scope);
|
|
92
|
+
if (!existsSync(file)) return false;
|
|
93
|
+
rmSync(file);
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function isOver(run: Run, now: Date = new Date()): boolean {
|
|
98
|
+
return now.getTime() >= Date.parse(run.until);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function parseDuration(text: string): number | null {
|
|
102
|
+
const m = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/.exec(text.trim());
|
|
103
|
+
if (!m) return null;
|
|
104
|
+
const [, h = "0", min = "0", s = "0"] = m;
|
|
105
|
+
const ms = (Number(h) * 3600 + Number(min) * 60 + Number(s)) * 1000;
|
|
106
|
+
return ms > 0 ? ms : null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function nextLocalTime(text: string, now: Date): Date | null {
|
|
110
|
+
const m = /^(\d{1,2}):(\d{2})$/.exec(text.trim());
|
|
111
|
+
if (!m) return null;
|
|
112
|
+
const [hour, minute] = [Number(m[1]), Number(m[2])];
|
|
113
|
+
if (hour > 23 || minute > 59) return null;
|
|
114
|
+
const target = hour * 60 + minute;
|
|
115
|
+
const ahead =
|
|
116
|
+
(target - localMinuteOfDay(now) + DAY_MINUTES) % DAY_MINUTES || DAY_MINUTES;
|
|
117
|
+
const minuteStart = now.getTime() - (now.getTime() % MINUTE_MS);
|
|
118
|
+
return new Date(minuteStart + ahead * MINUTE_MS);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** `--for 15m` / `1h30m`, or `--until 18:00` (the next such local time) or an ISO time. */
|
|
122
|
+
export function deadlineFrom(
|
|
123
|
+
spec: { for?: string; until?: string },
|
|
124
|
+
now: Date = new Date()
|
|
125
|
+
): Date {
|
|
126
|
+
if (spec.for !== undefined) {
|
|
127
|
+
const ms = parseDuration(spec.for);
|
|
128
|
+
if (ms === null)
|
|
129
|
+
throw new Error(`Not a duration: "${spec.for}". Use 15m, 2h or 1h30m.`);
|
|
130
|
+
return new Date(now.getTime() + ms);
|
|
131
|
+
}
|
|
132
|
+
if (spec.until !== undefined) {
|
|
133
|
+
const at = nextLocalTime(spec.until, now) ?? new Date(spec.until);
|
|
134
|
+
if (Number.isNaN(at.getTime()) || at <= now)
|
|
135
|
+
throw new Error(`Not a future time: "${spec.until}". Use HH:MM or an ISO time.`);
|
|
136
|
+
return at;
|
|
137
|
+
}
|
|
138
|
+
throw new Error("Give --for <duration> or --until <HH:MM>.");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function newRun(fields: {
|
|
142
|
+
kind: RunKind;
|
|
143
|
+
scope: string;
|
|
144
|
+
until: Date;
|
|
145
|
+
now?: Date;
|
|
146
|
+
task?: string | null;
|
|
147
|
+
report?: string | null;
|
|
148
|
+
tasks?: string[];
|
|
149
|
+
fallbacks?: string[];
|
|
150
|
+
rules?: string[];
|
|
151
|
+
}): Run {
|
|
152
|
+
return {
|
|
153
|
+
kind: fields.kind,
|
|
154
|
+
scope: fields.scope,
|
|
155
|
+
startedAt: (fields.now ?? new Date()).toISOString(),
|
|
156
|
+
until: fields.until.toISOString(),
|
|
157
|
+
task: fields.task ?? null,
|
|
158
|
+
report: fields.report ?? null,
|
|
159
|
+
tasks: fields.tasks ?? [],
|
|
160
|
+
fallbacks: fields.fallbacks ?? [],
|
|
161
|
+
rules: fields.rules ?? [],
|
|
162
|
+
log: [],
|
|
163
|
+
done: false,
|
|
164
|
+
stopBlocks: 0,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function untilLabel(run: Run): string {
|
|
169
|
+
return localHourMinute(new Date(run.until));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function minutesLeft(run: Run, now: Date = new Date()): number {
|
|
173
|
+
return Math.max(0, Math.ceil((Date.parse(run.until) - now.getTime()) / MINUTE_MS));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function numbered(items: string[]): string[] {
|
|
177
|
+
return items.map((item, i) => ` ${i + 1}. ${item}`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function logLines(run: Run): string[] {
|
|
181
|
+
return run.log.map((l) => ` - ${localHourMinute(new Date(l.at))} ${l.entry}`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function timeLine(run: Run, now: Date): string {
|
|
185
|
+
const name = run.kind === "unattended" ? "Unattended" : "Time box";
|
|
186
|
+
return isOver(run, now)
|
|
187
|
+
? `${name} ended at ${untilLabel(run)}.`
|
|
188
|
+
: `${name} until ${untilLabel(run)} (${minutesLeft(run, now)} min left).`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** The whole run as text: what `status` prints and what compaction recovery injects. */
|
|
192
|
+
export function describeRun(run: Run, now: Date = new Date()): string {
|
|
193
|
+
const lines = [timeLine(run, now)];
|
|
194
|
+
if (run.task) lines.push(`Task: ${run.task}`);
|
|
195
|
+
if (run.report) lines.push(`Report: ${run.report}`);
|
|
196
|
+
if (run.rules.length) lines.push("Rules:", ...numbered(run.rules));
|
|
197
|
+
if (run.tasks.length) lines.push("Tasks, in order:", ...numbered(run.tasks));
|
|
198
|
+
if (run.fallbacks.length) lines.push("Fallbacks:", ...numbered(run.fallbacks));
|
|
199
|
+
if (run.log.length) lines.push("Log:", ...logLines(run));
|
|
200
|
+
if (run.done) lines.push("Marked done.");
|
|
201
|
+
return lines.join("\n");
|
|
202
|
+
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { ANTIGRAVITY_WRITE_TOOLS, FILE_TARGET_KEYS, normalizeToolUse } from "./agent";
|
|
11
11
|
import { logDebug } from "./log";
|
|
12
|
+
import { type RunToolCall, runModeRefusal } from "./run-mode-gate";
|
|
12
13
|
import { checkBashCommand, checkFilePath } from "./security";
|
|
13
14
|
|
|
14
15
|
/** beforeShellExecution (Cursor only) — flat, no tool-name wrapper. */
|
|
@@ -100,8 +101,27 @@ function writesFile(toolName: string): boolean {
|
|
|
100
101
|
* `cwd` is passed rather than read so a test can pin the target of a refused
|
|
101
102
|
* command, which has no file of its own to name.
|
|
102
103
|
*/
|
|
104
|
+
function runModeGateRefusal(
|
|
105
|
+
call: RunToolCall,
|
|
106
|
+
cwd: string,
|
|
107
|
+
hookEventName?: string
|
|
108
|
+
): GateRefusal | null {
|
|
109
|
+
const message = runModeRefusal(call, cwd);
|
|
110
|
+
if (!message) return null;
|
|
111
|
+
return {
|
|
112
|
+
tool: call.tool,
|
|
113
|
+
target: call.filePath ?? cwd,
|
|
114
|
+
command: call.command,
|
|
115
|
+
reason: "run mode",
|
|
116
|
+
message,
|
|
117
|
+
hookEventName,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
103
121
|
export function decideRefusal(input: SecurityInput, cwd: string): GateRefusal | null {
|
|
104
122
|
if (isShellExec(input)) {
|
|
123
|
+
const runRefusal = runModeGateRefusal({ tool: "shell", command: input.command }, cwd);
|
|
124
|
+
if (runRefusal) return runRefusal;
|
|
105
125
|
const reason = checkBashCommand(input.command);
|
|
106
126
|
if (!reason) return null;
|
|
107
127
|
return {
|
|
@@ -129,6 +149,18 @@ export function decideRefusal(input: SecurityInput, cwd: string): GateRefusal |
|
|
|
129
149
|
"CommandLine",
|
|
130
150
|
"script",
|
|
131
151
|
]);
|
|
152
|
+
const filePath = firstStringArg(toolUse.toolInput, FILE_TARGET_KEYS);
|
|
153
|
+
const runRefusal = runModeGateRefusal(
|
|
154
|
+
{
|
|
155
|
+
tool: toolUse.toolName,
|
|
156
|
+
command: runsShellCommand(toolUse.toolName) ? command : undefined,
|
|
157
|
+
filePath: writesFile(toolUse.toolName) ? filePath : undefined,
|
|
158
|
+
},
|
|
159
|
+
cwd,
|
|
160
|
+
toolUse.hookEventName
|
|
161
|
+
);
|
|
162
|
+
if (runRefusal) return runRefusal;
|
|
163
|
+
|
|
132
164
|
if (runsShellCommand(toolUse.toolName) && command) {
|
|
133
165
|
const reason = checkBashCommand(command);
|
|
134
166
|
// "No output" from a downstream tool is indistinguishable between "denied,
|
|
@@ -148,7 +180,6 @@ export function decideRefusal(input: SecurityInput, cwd: string): GateRefusal |
|
|
|
148
180
|
}
|
|
149
181
|
}
|
|
150
182
|
|
|
151
|
-
const filePath = firstStringArg(toolUse.toolInput, FILE_TARGET_KEYS);
|
|
152
183
|
if (writesFile(toolUse.toolName) && filePath) {
|
|
153
184
|
const reason = checkFilePath(filePath);
|
|
154
185
|
if (reason) {
|
|
@@ -297,6 +297,22 @@ function withoutPalCliInvocations(cmd: string): string {
|
|
|
297
297
|
.join("");
|
|
298
298
|
}
|
|
299
299
|
|
|
300
|
+
const STREAM_REDIRECT = /\d?>&\d/g;
|
|
301
|
+
const OUTPUT_FILTER = /^\s*(?:head|tail)\b/;
|
|
302
|
+
|
|
303
|
+
/** True when the command is `pal cli …`, optionally chained to more of it or piped into head/tail. */
|
|
304
|
+
export function runsOnlyPalCli(cmd: string): boolean {
|
|
305
|
+
const pieces = splitOnUnquotedSeparators(cmd.replace(STREAM_REDIRECT, " "))
|
|
306
|
+
.map((piece) => piece.text)
|
|
307
|
+
.filter((text) => text.trim() !== "");
|
|
308
|
+
const [first, ...rest] = pieces;
|
|
309
|
+
return (
|
|
310
|
+
first !== undefined &&
|
|
311
|
+
isPalCliInvocation(first) &&
|
|
312
|
+
rest.every((text) => isPalCliInvocation(text) || OUTPUT_FILTER.test(text))
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
300
316
|
function unquotedSegments(cmd: string): string[] {
|
|
301
317
|
return splitOnUnquotedSeparators(cmd).map((piece) => piece.text.trim());
|
|
302
318
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One send-back per reply: the stop right after a reply check sent a reply back
|
|
3
|
+
* lets the corrected reply through. A stop held for any other reason, such as an
|
|
4
|
+
* unattended run, leaves the next reply checked again.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, rmSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { resolve } from "node:path";
|
|
9
|
+
import { hookSessionId } from "./hook-turn";
|
|
10
|
+
import { ensureDir, paths } from "./paths";
|
|
11
|
+
import type { StopTurnPayload } from "./stop";
|
|
12
|
+
|
|
13
|
+
function isContinuedStop(payload: StopTurnPayload): boolean {
|
|
14
|
+
return (
|
|
15
|
+
payload.stop_hook_active === true ||
|
|
16
|
+
(payload.loop_count ?? 0) > 0 ||
|
|
17
|
+
(payload.executionNum ?? 0) > 0
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function markerOf(session: string): string {
|
|
22
|
+
const dir = ensureDir(resolve(paths.state(), "reply-sent-back"));
|
|
23
|
+
return resolve(dir, session.replace(/[^\w-]/g, "_"));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function alreadySentBack(payload: StopTurnPayload): boolean {
|
|
27
|
+
if (!isContinuedStop(payload)) return false;
|
|
28
|
+
const session = hookSessionId(payload);
|
|
29
|
+
return session ? existsSync(markerOf(session)) : true;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function rememberSendBack(payload: StopTurnPayload, sentBack: boolean): void {
|
|
33
|
+
const session = hookSessionId(payload);
|
|
34
|
+
if (!session) return;
|
|
35
|
+
if (sentBack) writeFileSync(markerOf(session), "");
|
|
36
|
+
else rmSync(markerOf(session), { force: true });
|
|
37
|
+
}
|
|
@@ -74,6 +74,10 @@ function gitDirOf(start: string): string | null {
|
|
|
74
74
|
}
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
export function isInGitRepo(cwd: string): boolean {
|
|
78
|
+
return gitDirOf(cwd) !== null;
|
|
79
|
+
}
|
|
80
|
+
|
|
77
81
|
export function gitBranchOf(cwd: string): string | null {
|
|
78
82
|
try {
|
|
79
83
|
const gitDir = gitDirOf(cwd);
|
|
@@ -71,6 +71,18 @@ export function localClock(now: Date = new Date()): { hour: number; weekday: str
|
|
|
71
71
|
return { hour: Number(f.hour), weekday: f.weekday };
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/** Minutes since local midnight in the principal's timezone. */
|
|
75
|
+
export function localMinuteOfDay(now: Date = new Date()): number {
|
|
76
|
+
const f = fieldsIn(now, zoneOrUtc(identity().principal.timezone));
|
|
77
|
+
return Number(f.hour) * 60 + Number(f.minute);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** HH:MM in the principal's timezone. */
|
|
81
|
+
export function localHourMinute(at: Date): string {
|
|
82
|
+
const f = fieldsIn(at, zoneOrUtc(identity().principal.timezone));
|
|
83
|
+
return `${f.hour}:${f.minute}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
74
86
|
export function getWallClockReminder(now: Date = new Date()): string | null {
|
|
75
87
|
if (!isEnabled("wallClock")) return null;
|
|
76
88
|
const line = wallClockLine(now, identity().principal.timezone);
|