portable-agent-layer 0.113.0 → 0.114.0-canary.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/assets/schema/pal-settings.schema.json +4 -0
- package/assets/templates/pal-settings.json +1 -0
- package/package.json +4 -2
- package/src/hooks/LoadContext.ts +2 -0
- package/src/hooks/StopOrchestrator.ts +1 -1
- package/src/hooks/handlers/inject-retrieval.ts +1 -1
- package/src/hooks/lib/claim-check.ts +8 -0
- package/src/hooks/lib/context.ts +2 -2
- package/src/hooks/lib/decision-label.ts +70 -0
- package/src/hooks/lib/models.ts +5 -1
- package/src/hooks/lib/work-session.ts +28 -0
- package/src/hooks/lib/work-tree-context.ts +13 -4
- package/src/hooks/lib/work-tree-gate.ts +57 -7
- package/src/hooks/lib/work-tree-git.ts +4 -2
- package/src/hooks/lib/work-tree-render.ts +16 -4
- package/src/hooks/lib/work-tree.ts +63 -23
- package/src/targets/opencode/plugin.ts +7 -3
|
@@ -82,6 +82,10 @@
|
|
|
82
82
|
"type": "boolean",
|
|
83
83
|
"description": "Opt-in, off by default until 1.0. Send a turn back when it committed, made a branch, opened a PR or published an artifact without recording it in the work tree (pal cli work). Needs workTree."
|
|
84
84
|
},
|
|
85
|
+
"workTreeDecisionGate": {
|
|
86
|
+
"type": "boolean",
|
|
87
|
+
"description": "Opt-in, off by default until 1.0. Send a turn back when a small-model check finds it settled a decision that no pal cli work decide recorded. One small-model call at the end of every turn. Needs workTree."
|
|
88
|
+
},
|
|
85
89
|
"dueReminders": {
|
|
86
90
|
"type": "boolean",
|
|
87
91
|
"description": "Opt-in, off by default until 1.0. Due maintenance reminders (learning analysis, relationship reflect) with every prompt until a reply names their command; then quiet until the next day."
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "portable-agent-layer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.114.0-canary.2",
|
|
4
4
|
"description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -74,7 +74,9 @@
|
|
|
74
74
|
"eval:correction-kind": "bun eval/correction-kind/build-prompt.ts && bun eval/run.ts correction-kind",
|
|
75
75
|
"eval:correction-kind:test": "bun eval/correction-kind/test-set.ts",
|
|
76
76
|
"eval:rule-drafter": "bun eval/rule-drafter/build-prompt.ts && bun eval/run.ts rule-drafter",
|
|
77
|
-
"eval:rule-drafter:test": "bun eval/rule-drafter/test-set.ts"
|
|
77
|
+
"eval:rule-drafter:test": "bun eval/rule-drafter/test-set.ts",
|
|
78
|
+
"eval:decision": "bun eval/decision/score.ts validation",
|
|
79
|
+
"eval:decision:test": "bun eval/decision/score.ts test"
|
|
78
80
|
},
|
|
79
81
|
"lint-staged": {
|
|
80
82
|
"*.ts": [
|
package/src/hooks/LoadContext.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { buildClaudeMd, regenerateIfNeeded } from "./lib/claude-md";
|
|
|
14
14
|
import { type AgentTarget, buildSystemReminder } from "./lib/context";
|
|
15
15
|
import { logContextInjected } from "./lib/context-overhead";
|
|
16
16
|
import { duplicatesCursorHooks } from "./lib/cursor-shadow";
|
|
17
|
+
import { hookSessionId } from "./lib/hook-turn";
|
|
17
18
|
import { logContextSnapshot, logDebug, logError } from "./lib/log";
|
|
18
19
|
import {
|
|
19
20
|
contextEnvelope,
|
|
@@ -62,6 +63,7 @@ try {
|
|
|
62
63
|
const reminder = buildSystemReminder({
|
|
63
64
|
agent: target,
|
|
64
65
|
withoutHandoff: wanted === "without-handoff",
|
|
66
|
+
sessionId: hookSessionId(input),
|
|
65
67
|
});
|
|
66
68
|
if (!reminder) process.exit(0);
|
|
67
69
|
logContextSnapshot(reminder);
|
|
@@ -89,7 +89,7 @@ export async function getPromptContext(
|
|
|
89
89
|
getWallClockReminder(),
|
|
90
90
|
userText && withinBudget(() => promptRulesReminder(userText, sessionId), BUDGET_MS),
|
|
91
91
|
withinBudget(() => getRepoStateReminder(), BUDGET_MS),
|
|
92
|
-
withinBudget(() => workTreePromptLine(userText), BUDGET_MS),
|
|
92
|
+
withinBudget(() => workTreePromptLine(userText, undefined, sessionId), BUDGET_MS),
|
|
93
93
|
userText && withinBudget(() => observeTurn(userText, sessionId), BUDGET_MS),
|
|
94
94
|
withinBudget(() => dueNudgeReminder(), BUDGET_MS),
|
|
95
95
|
userText && getSteeringReminder(userText),
|
|
@@ -137,6 +137,14 @@ function isUserPrompt(entry: TranscriptLine): boolean {
|
|
|
137
137
|
return text !== null && !isSystemText(text) && !isCodexInjectedContext(text);
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
export function promptThisTurn(lines: string[]): string | null {
|
|
141
|
+
const prompts = lines
|
|
142
|
+
.map(parsed)
|
|
143
|
+
.filter((e): e is TranscriptLine => e !== null && isUserPrompt(e));
|
|
144
|
+
const last = prompts.at(-1);
|
|
145
|
+
return last ? promptText(last) : null;
|
|
146
|
+
}
|
|
147
|
+
|
|
140
148
|
const ran = (input: unknown) => JSON.stringify(input ?? {});
|
|
141
149
|
|
|
142
150
|
function callsIn(entry: TranscriptLine, tools: ReadonlySet<string>): string[] {
|
package/src/hooks/lib/context.ts
CHANGED
|
@@ -194,7 +194,7 @@ export type AgentTarget = Extract<
|
|
|
194
194
|
* wisdom, opinions) that load natively via @imports in CLAUDE.md.
|
|
195
195
|
*/
|
|
196
196
|
export function buildSystemReminder(
|
|
197
|
-
opts: { agent?: AgentTarget; withoutHandoff?: boolean } = {}
|
|
197
|
+
opts: { agent?: AgentTarget; withoutHandoff?: boolean; sessionId?: string } = {}
|
|
198
198
|
): string {
|
|
199
199
|
// Semi-static sections loaded natively via @imports (Claude Code) or
|
|
200
200
|
// instructions[] (opencode). Skip them from hook output for those agents.
|
|
@@ -224,7 +224,7 @@ export function buildSystemReminder(
|
|
|
224
224
|
!opts.withoutHandoff && settings.isEnabled("handoff")
|
|
225
225
|
? loadHandoffContext(sessionDir())
|
|
226
226
|
: "";
|
|
227
|
-
const workTree = loadWorkTreeContext(sessionDir());
|
|
227
|
+
const workTree = loadWorkTreeContext(sessionDir(), opts.sessionId);
|
|
228
228
|
const dueNudges = opts.agent === "cursor" ? dueNudgeSections() : [];
|
|
229
229
|
const parts: string[] = [];
|
|
230
230
|
if (startup) parts.push(startup);
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a turn settled a decision. Code can see that a turn committed or
|
|
3
|
+
* opened a PR, but a decision is only words, so the work-tree gate asks the
|
|
4
|
+
* small model and sends the turn back when nothing was recorded.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface DecisionLabel {
|
|
8
|
+
decision: boolean;
|
|
9
|
+
what: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const DECISION_SYSTEM = `You read one turn of a working session between a user and an AI assistant: the user's message and the end of the assistant's reply. Say whether the turn settled a decision.
|
|
13
|
+
|
|
14
|
+
A decision is a choice between alternatives about the work that holds beyond this turn: a policy, a design, a direction, a scope (what is in, out, parked or dropped), or a standing way of working. Examples: "make them opt in for now", "we only track new posts, not past ones", "we don't need the predictor", "PRs go to canary from now on".
|
|
15
|
+
|
|
16
|
+
A decision is often a few words that answer something open earlier: picking one of the options the assistant offered, keeping something where it is, saying something is not needed or should wait, changing how a thing behaves. Read the reply to see what the user's words settled; a short message can still be a decision.
|
|
17
|
+
|
|
18
|
+
These are not decisions:
|
|
19
|
+
- a go-ahead or approval of a plan or of the assistant's proposals as they stand ("go", "yes pls", "build all of them", "ok to both, build it"), unless it also picks between alternatives or changes the plan;
|
|
20
|
+
- where the work goes (which branch or PR) and other bookkeeping the assistant handles;
|
|
21
|
+
- a plain request for work ("fix CI", "push and open a PR", "plan it", "list our tasks"), including which step comes first;
|
|
22
|
+
- a question, even one about what to decide or what was decided before;
|
|
23
|
+
- a fact, a status report, or an idea floated for discussion;
|
|
24
|
+
- a recommendation or proposal from the assistant that the user has not accepted;
|
|
25
|
+
- small implementation choices the assistant makes while working.
|
|
26
|
+
|
|
27
|
+
Most turns settle no decision. When unsure, answer false.
|
|
28
|
+
|
|
29
|
+
Set what to the decision in under fifteen words, or to an empty string when there is none.`;
|
|
30
|
+
|
|
31
|
+
const DECISION_SCHEMA = {
|
|
32
|
+
type: "object",
|
|
33
|
+
properties: {
|
|
34
|
+
decision: { type: "boolean" },
|
|
35
|
+
what: { type: "string" },
|
|
36
|
+
},
|
|
37
|
+
required: ["decision", "what"],
|
|
38
|
+
additionalProperties: false,
|
|
39
|
+
} as const;
|
|
40
|
+
|
|
41
|
+
const MESSAGE_MAX = 1500;
|
|
42
|
+
const REPLY_END_MAX = 1500;
|
|
43
|
+
|
|
44
|
+
export function decisionUser(message: string, reply: string) {
|
|
45
|
+
return `The user's message:\n<message>\n${message.slice(0, MESSAGE_MAX)}\n</message>\n\nThe end of the assistant's reply:\n<reply_end>\n${reply.slice(-REPLY_END_MAX)}\n</reply_end>`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function decisionRequest(message: string, reply: string, sessionId?: string) {
|
|
49
|
+
return {
|
|
50
|
+
system: DECISION_SYSTEM,
|
|
51
|
+
user: decisionUser(message, reply),
|
|
52
|
+
maxTokens: 100,
|
|
53
|
+
timeout: 60_000,
|
|
54
|
+
jsonSchema: DECISION_SCHEMA,
|
|
55
|
+
caller: "decision-label",
|
|
56
|
+
sessionId,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function parseDecision(output: string | null | undefined): DecisionLabel | null {
|
|
61
|
+
if (!output) return null;
|
|
62
|
+
try {
|
|
63
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
64
|
+
if (typeof parsed.decision !== "boolean") return null;
|
|
65
|
+
const what = typeof parsed.what === "string" ? parsed.what.trim() : "";
|
|
66
|
+
return { decision: parsed.decision, what: parsed.decision ? what : "" };
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
package/src/hooks/lib/models.ts
CHANGED
|
@@ -76,7 +76,11 @@ export function isFixedModelRoute(route: string): route is FixedModelRoute {
|
|
|
76
76
|
* caller, whose eval is iterated on 5.5 until it passes. The rest stay on
|
|
77
77
|
* HAIKU_MODEL until theirs is evaluated.
|
|
78
78
|
*/
|
|
79
|
-
const ANTHROPIC_ON_HAIKU_5_5 = new Set([
|
|
79
|
+
const ANTHROPIC_ON_HAIKU_5_5 = new Set([
|
|
80
|
+
"rule-drafter",
|
|
81
|
+
"correction-kind",
|
|
82
|
+
"decision-label",
|
|
83
|
+
]);
|
|
80
84
|
|
|
81
85
|
function evaluatedModel(
|
|
82
86
|
models: RouteModels,
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which session a piece of work-tree activity belongs to, so parallel sessions
|
|
3
|
+
* in one project each keep their own active node. A hook knows its session
|
|
4
|
+
* from the payload, but `pal cli work` runs in the agent's shell and knows it
|
|
5
|
+
* only from what the agent exports there, so only agents that export it get
|
|
6
|
+
* their own lane; the rest share one, as every session did before.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { type AgentType, getActiveAgent } from "./agent";
|
|
10
|
+
|
|
11
|
+
const SHELL_SESSION_ENV: Partial<Record<AgentType, string>> = {
|
|
12
|
+
claude: "CLAUDE_CODE_SESSION_ID",
|
|
13
|
+
codex: "CODEX_THREAD_ID",
|
|
14
|
+
copilot: "COPILOT_AGENT_SESSION_ID",
|
|
15
|
+
antigravity: "ANTIGRAVITY_CONVERSATION_ID",
|
|
16
|
+
opencode: "PAL_OPENCODE_SESSION_ID",
|
|
17
|
+
cursor: "CURSOR_CONVERSATION_ID",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function workSession(
|
|
21
|
+
hookSession?: string,
|
|
22
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
23
|
+
agent: AgentType = getActiveAgent()
|
|
24
|
+
): string | undefined {
|
|
25
|
+
const variable = SHELL_SESSION_ENV[agent];
|
|
26
|
+
if (!variable) return undefined;
|
|
27
|
+
return hookSession || env[variable] || undefined;
|
|
28
|
+
}
|
|
@@ -6,20 +6,29 @@
|
|
|
6
6
|
import { reactionTo } from "./interaction-reaction";
|
|
7
7
|
import { sessionDir } from "./session-dir";
|
|
8
8
|
import { isOptedIn } from "./settings";
|
|
9
|
+
import { workSession } from "./work-session";
|
|
9
10
|
import { readTree, scopeOf } from "./work-tree";
|
|
10
11
|
import { renderPromptLine, renderSessionContext } from "./work-tree-render";
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
function sessionTree(cwd: string, hookSession: string | undefined) {
|
|
14
|
+
return readTree(scopeOf(cwd), undefined, workSession(hookSession));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function loadWorkTreeContext(
|
|
18
|
+
cwd: string = sessionDir(),
|
|
19
|
+
hookSession?: string
|
|
20
|
+
): string {
|
|
13
21
|
if (!isOptedIn("workTree")) return "";
|
|
14
|
-
return renderSessionContext(
|
|
22
|
+
return renderSessionContext(sessionTree(cwd, hookSession));
|
|
15
23
|
}
|
|
16
24
|
|
|
17
25
|
export function workTreePromptLine(
|
|
18
26
|
userText: string,
|
|
19
|
-
cwd: string = sessionDir()
|
|
27
|
+
cwd: string = sessionDir(),
|
|
28
|
+
hookSession?: string
|
|
20
29
|
): string | null {
|
|
21
30
|
if (!isOptedIn("workTree")) return null;
|
|
22
31
|
const newTopic = userText !== "" && reactionTo(userText, undefined) === "new-topic";
|
|
23
|
-
const line = renderPromptLine(
|
|
32
|
+
const line = renderPromptLine(sessionTree(cwd, hookSession), newTopic);
|
|
24
33
|
return line ? `<system-reminder>${line}</system-reminder>` : null;
|
|
25
34
|
}
|
|
@@ -4,16 +4,21 @@
|
|
|
4
4
|
* nothing in the tree is sent back once to record it.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { callsThisTurn, commandsThisTurn } from "./claim-check";
|
|
7
|
+
import { callsThisTurn, commandsThisTurn, promptThisTurn } from "./claim-check";
|
|
8
8
|
import { alreadySentBack, transcriptLines } from "./claim-log";
|
|
9
|
+
import { type DecisionLabel, decisionRequest, parseDecision } from "./decision-label";
|
|
10
|
+
import { hookFinalReply, hookSessionId } from "./hook-turn";
|
|
11
|
+
import { inference } from "./inference";
|
|
9
12
|
import { sessionDir } from "./session-dir";
|
|
10
13
|
import { isOptedIn } from "./settings";
|
|
11
14
|
import type { StopTurnPayload } from "./stop";
|
|
15
|
+
import { workSession } from "./work-session";
|
|
12
16
|
import { readTree, scopeOf } from "./work-tree";
|
|
13
17
|
import { attachGitProgress } from "./work-tree-git";
|
|
14
18
|
import { renderPromptLine } from "./work-tree-render";
|
|
15
19
|
|
|
16
20
|
const RECORDS_WORK_RE = /\bpal\s+cli\s+work\b/;
|
|
21
|
+
const RECORDS_DECISION_RE = /\bpal\s+cli\s+work\s+decide\b/;
|
|
17
22
|
|
|
18
23
|
const PRODUCERS: { what: string; re: RegExp }[] = [
|
|
19
24
|
{ what: "committed", re: /\bgit\s+commit\b/ },
|
|
@@ -34,8 +39,17 @@ function producedThisTurn(commands: string[], lines: string[]): string[] {
|
|
|
34
39
|
return produced;
|
|
35
40
|
}
|
|
36
41
|
|
|
37
|
-
function
|
|
38
|
-
const
|
|
42
|
+
function whereWorkStands(scope: string, payload: StopTurnPayload): string {
|
|
43
|
+
const tree = readTree(scope, undefined, workSession(hookSessionId(payload)));
|
|
44
|
+
return renderPromptLine(tree, false) ?? "Work: nothing recorded yet";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function sendBackReason(
|
|
48
|
+
produced: string[],
|
|
49
|
+
scope: string,
|
|
50
|
+
payload: StopTurnPayload
|
|
51
|
+
): string {
|
|
52
|
+
const where = whereWorkStands(scope, payload);
|
|
39
53
|
return [
|
|
40
54
|
`This turn ${produced.join(", ")} but recorded nothing in the work tree (${where}).`,
|
|
41
55
|
'Record where the work stands with `pal cli work stopped "…"`, plus `decide` or `attach` where they fit;',
|
|
@@ -53,15 +67,51 @@ export function workTreeSendBack(
|
|
|
53
67
|
const commands = commandsThisTurn(lines);
|
|
54
68
|
if (commands === null || commands.some((c) => RECORDS_WORK_RE.test(c))) return null;
|
|
55
69
|
const produced = producedThisTurn(commands, lines);
|
|
56
|
-
return produced.length > 0 ? sendBackReason(produced, scopeOf(cwd)) : null;
|
|
70
|
+
return produced.length > 0 ? sendBackReason(produced, scopeOf(cwd), payload) : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
type LabelDecision = (message: string, reply: string) => Promise<DecisionLabel | null>;
|
|
74
|
+
|
|
75
|
+
async function labelWithInference(message: string, reply: string) {
|
|
76
|
+
const result = await inference(decisionRequest(message, reply));
|
|
77
|
+
return result.success ? parseDecision(result.output) : null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function decisionReason(what: string, scope: string, payload: StopTurnPayload): string {
|
|
81
|
+
return [
|
|
82
|
+
`This turn settled a decision (${what}) but recorded none in the work tree (${whereWorkStands(scope, payload)}).`,
|
|
83
|
+
'Record it with `pal cli work decide "…"`; if no node fits, open one first with `pal cli work open "…"`.',
|
|
84
|
+
].join(" ");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function decisionSendBack(
|
|
88
|
+
payload: StopTurnPayload,
|
|
89
|
+
lines: string[],
|
|
90
|
+
cwd: string,
|
|
91
|
+
label: LabelDecision = labelWithInference
|
|
92
|
+
): Promise<string | null> {
|
|
93
|
+
if (!isOptedIn("workTree") || !isOptedIn("workTreeDecisionGate")) return null;
|
|
94
|
+
if (alreadySentBack(payload)) return null;
|
|
95
|
+
const commands = commandsThisTurn(lines);
|
|
96
|
+
if (commands === null || commands.some((c) => RECORDS_DECISION_RE.test(c))) return null;
|
|
97
|
+
const message = promptThisTurn(lines);
|
|
98
|
+
const reply = hookFinalReply(payload);
|
|
99
|
+
if (!message || !reply) return null;
|
|
100
|
+
const decided = await label(message, reply);
|
|
101
|
+
return decided?.decision ? decisionReason(decided.what, scopeOf(cwd), payload) : null;
|
|
57
102
|
}
|
|
58
103
|
|
|
59
104
|
/** The reason to send the turn back with, or null after attaching what git moved. */
|
|
60
|
-
export function stopWorkTree(
|
|
105
|
+
export async function stopWorkTree(
|
|
106
|
+
payload: StopTurnPayload | null
|
|
107
|
+
): Promise<string | null> {
|
|
61
108
|
if (!payload || !isOptedIn("workTree")) return null;
|
|
62
109
|
const cwd = sessionDir();
|
|
63
|
-
const
|
|
110
|
+
const lines = transcriptLines(payload);
|
|
111
|
+
const sendBack =
|
|
112
|
+
workTreeSendBack(payload, lines, cwd) ??
|
|
113
|
+
(await decisionSendBack(payload, lines, cwd));
|
|
64
114
|
if (sendBack) return sendBack;
|
|
65
|
-
attachGitProgress(cwd);
|
|
115
|
+
attachGitProgress(cwd, undefined, undefined, workSession(hookSessionId(payload)));
|
|
66
116
|
return null;
|
|
67
117
|
}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { workSession } from "./work-session";
|
|
7
8
|
import { appendEvent, readTree, scopeOf } from "./work-tree";
|
|
8
9
|
|
|
9
10
|
const GIT_TIMEOUT_MS = 1000;
|
|
@@ -57,10 +58,11 @@ function ownCommitsSince(cwd: string, from: string, to: string, email: string):
|
|
|
57
58
|
export function attachGitProgress(
|
|
58
59
|
cwd: string,
|
|
59
60
|
file?: string,
|
|
60
|
-
now: Date = new Date()
|
|
61
|
+
now: Date = new Date(),
|
|
62
|
+
session: string | undefined = workSession()
|
|
61
63
|
): void {
|
|
62
64
|
const scope = scopeOf(cwd);
|
|
63
|
-
const tree = readTree(scope, file);
|
|
65
|
+
const tree = readTree(scope, file, session);
|
|
64
66
|
if (tree.nodes.size === 0) return;
|
|
65
67
|
const current = snapshot(cwd);
|
|
66
68
|
if (!current) return;
|
|
@@ -3,7 +3,13 @@
|
|
|
3
3
|
* compacted session starts with, and the one line every prompt carries.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
activeElsewhere,
|
|
8
|
+
activePath,
|
|
9
|
+
nodesWithStatus,
|
|
10
|
+
type ScopeTree,
|
|
11
|
+
type WorkNode,
|
|
12
|
+
} from "./work-tree";
|
|
7
13
|
|
|
8
14
|
const SESSION_BUDGET = 2_000;
|
|
9
15
|
const RECENT_DECISIONS = 3;
|
|
@@ -102,7 +108,7 @@ export function renderTreeList(tree: ScopeTree, includeDone: boolean): string {
|
|
|
102
108
|
function activeSection(tree: ScopeTree): string[] {
|
|
103
109
|
const path = activePath(tree);
|
|
104
110
|
const active = path.at(-1);
|
|
105
|
-
if (!active) return ["Active: none — enter a
|
|
111
|
+
if (!active) return ["Active: none in this session — enter a node below or open one."];
|
|
106
112
|
const lines = [`Active: ${label(active)}${stoppedClause(active)}`];
|
|
107
113
|
if (path.length > 1) lines.push(` Inside: ${path.map((n) => n.title).join(" › ")}`);
|
|
108
114
|
for (const d of active.decisions.slice(-RECENT_DECISIONS))
|
|
@@ -122,6 +128,10 @@ export function renderSessionContext(tree: ScopeTree): string {
|
|
|
122
128
|
const lines = [
|
|
123
129
|
`## Work tree — ${tree.scope}`,
|
|
124
130
|
...activeSection(tree),
|
|
131
|
+
...listSection(
|
|
132
|
+
"Active in other sessions:",
|
|
133
|
+
activeElsewhere(tree).map((n) => `${label(n)}${stoppedClause(n)}`)
|
|
134
|
+
),
|
|
125
135
|
...listSection(
|
|
126
136
|
"Waiting on the user:",
|
|
127
137
|
nodesWithStatus(tree, "waiting").map((n) => `${label(n)}: ${n.waitingOn}`)
|
|
@@ -149,9 +159,11 @@ export function renderPromptLine(tree: ScopeTree, newTopic: boolean): string | n
|
|
|
149
159
|
if (tree.nodes.size === 0) return null;
|
|
150
160
|
const active = tree.active ? tree.nodes.get(tree.active) : undefined;
|
|
151
161
|
const parked = nodesWithStatus(tree, "parked").length;
|
|
162
|
+
const elsewhere = activeElsewhere(tree).length;
|
|
152
163
|
const head = active
|
|
153
164
|
? `Work: ${label(active)}${stoppedClause(active)}`
|
|
154
|
-
: "Work: no active node";
|
|
155
|
-
const
|
|
165
|
+
: "Work: no active node in this session";
|
|
166
|
+
const tail = elsewhere ? ` · ${elsewhere} active in other sessions` : "";
|
|
167
|
+
const line = clip(`${head} · ${parked} parked${tail}`, PROMPT_LINE_MAX);
|
|
156
168
|
return newTopic ? `${line}\n${NEW_TOPIC_HINT}` : line;
|
|
157
169
|
}
|
|
@@ -9,6 +9,7 @@ import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
|
|
9
9
|
import { resolve } from "node:path";
|
|
10
10
|
import { ensureDir, paths } from "./paths";
|
|
11
11
|
import { readAllProjects, resolveProjectFromCwd } from "./projects";
|
|
12
|
+
import { workSession } from "./work-session";
|
|
12
13
|
|
|
13
14
|
export type NodeStatus = "active" | "parked" | "waiting" | "done";
|
|
14
15
|
|
|
@@ -41,6 +42,7 @@ interface EventBase {
|
|
|
41
42
|
ts: string;
|
|
42
43
|
scope: string;
|
|
43
44
|
node: string;
|
|
45
|
+
session?: string;
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
export type WorkEvent = EventBase &
|
|
@@ -102,9 +104,11 @@ interface GitCursor {
|
|
|
102
104
|
head: string;
|
|
103
105
|
}
|
|
104
106
|
|
|
107
|
+
/** `active` is the viewing session's node; `lanes` holds every session's, keyed by session ("" for none). */
|
|
105
108
|
export interface ScopeTree {
|
|
106
109
|
scope: string;
|
|
107
110
|
active: string | null;
|
|
111
|
+
lanes: Map<string, string>;
|
|
108
112
|
nodes: Map<string, WorkNode>;
|
|
109
113
|
gitCursor: GitCursor | null;
|
|
110
114
|
}
|
|
@@ -149,13 +153,15 @@ export function appendEvent<T extends WorkEvent["type"]>(
|
|
|
149
153
|
type: T,
|
|
150
154
|
payload: Payload<T>,
|
|
151
155
|
file: string = workTreeFile(),
|
|
152
|
-
now: Date = new Date()
|
|
156
|
+
now: Date = new Date(),
|
|
157
|
+
session: string | undefined = workSession()
|
|
153
158
|
): WorkEvent {
|
|
154
159
|
const event = {
|
|
155
160
|
id: newNodeId(now.getTime()),
|
|
156
161
|
ts: now.toISOString(),
|
|
157
162
|
scope,
|
|
158
163
|
node,
|
|
164
|
+
...(session && { session }),
|
|
159
165
|
type,
|
|
160
166
|
...payload,
|
|
161
167
|
} as WorkEvent;
|
|
@@ -186,50 +192,64 @@ function emptyNode(
|
|
|
186
192
|
};
|
|
187
193
|
}
|
|
188
194
|
|
|
189
|
-
function
|
|
190
|
-
|
|
195
|
+
function laneOf(event: WorkEvent): string {
|
|
196
|
+
return event.session ?? "";
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function parkLane(tree: ScopeTree, lane: string): void {
|
|
200
|
+
const current = tree.nodes.get(tree.lanes.get(lane) ?? "");
|
|
191
201
|
if (current?.status === "active") current.status = "parked";
|
|
192
|
-
tree.
|
|
202
|
+
tree.lanes.delete(lane);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The lane that held the node, if one did. */
|
|
206
|
+
function release(tree: ScopeTree, node: WorkNode): string | undefined {
|
|
207
|
+
const held = [...tree.lanes].find(([, id]) => id === node.id)?.[0];
|
|
208
|
+
if (held !== undefined) tree.lanes.delete(held);
|
|
209
|
+
return held;
|
|
193
210
|
}
|
|
194
211
|
|
|
195
|
-
|
|
196
|
-
|
|
212
|
+
/** A node is active in one session at a time, so entering it takes it over. */
|
|
213
|
+
function activate(tree: ScopeTree, node: WorkNode, lane: string): void {
|
|
214
|
+
if (tree.lanes.get(lane) !== node.id) parkLane(tree, lane);
|
|
215
|
+
release(tree, node);
|
|
197
216
|
node.status = "active";
|
|
198
217
|
node.waitingOn = null;
|
|
199
|
-
tree.
|
|
218
|
+
tree.lanes.set(lane, node.id);
|
|
200
219
|
}
|
|
201
220
|
|
|
202
|
-
|
|
203
|
-
|
|
221
|
+
function heldElsewhere(tree: ScopeTree, node: WorkNode): boolean {
|
|
222
|
+
return [...tree.lanes.values()].includes(node.id);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Finishing a detour hands the work back to the nearest unfinished ancestor no other session holds. */
|
|
226
|
+
function returnToParent(tree: ScopeTree, node: WorkNode, lane: string): void {
|
|
204
227
|
let parent = node.parent ? tree.nodes.get(node.parent) : undefined;
|
|
205
228
|
while (parent && parent.status === "done")
|
|
206
229
|
parent = parent.parent ? tree.nodes.get(parent.parent) : undefined;
|
|
207
|
-
if (parent) activate(tree, parent);
|
|
230
|
+
if (parent && !heldElsewhere(tree, parent)) activate(tree, parent, lane);
|
|
208
231
|
}
|
|
209
232
|
|
|
210
233
|
function applyToNode(tree: ScopeTree, node: WorkNode, event: WorkEvent): void {
|
|
211
234
|
switch (event.type) {
|
|
212
235
|
case "enter":
|
|
213
|
-
activate(tree, node);
|
|
236
|
+
activate(tree, node, laneOf(event));
|
|
214
237
|
break;
|
|
215
238
|
case "park":
|
|
216
239
|
if (event.stoppedAt) node.stoppedAt = event.stoppedAt;
|
|
217
240
|
node.status = "parked";
|
|
218
|
-
|
|
241
|
+
release(tree, node);
|
|
219
242
|
break;
|
|
220
243
|
case "wait":
|
|
221
244
|
node.status = "waiting";
|
|
222
245
|
node.waitingOn = event.on;
|
|
223
|
-
|
|
246
|
+
release(tree, node);
|
|
224
247
|
break;
|
|
225
248
|
case "done": {
|
|
226
|
-
const wasActive = tree.active === node.id;
|
|
227
249
|
node.status = "done";
|
|
228
250
|
node.summary = event.summary ?? node.summary;
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
returnToParent(tree, node);
|
|
232
|
-
}
|
|
251
|
+
const lane = release(tree, node);
|
|
252
|
+
if (lane !== undefined) returnToParent(tree, node, lane);
|
|
233
253
|
break;
|
|
234
254
|
}
|
|
235
255
|
case "stopped":
|
|
@@ -262,21 +282,41 @@ function apply(tree: ScopeTree, event: WorkEvent): void {
|
|
|
262
282
|
const node = emptyNode(event.node, event.title, event.parent, event.ts);
|
|
263
283
|
node.links = event.links ?? {};
|
|
264
284
|
tree.nodes.set(node.id, node);
|
|
265
|
-
activate(tree, node);
|
|
285
|
+
activate(tree, node, laneOf(event));
|
|
266
286
|
return;
|
|
267
287
|
}
|
|
268
288
|
const node = tree.nodes.get(event.node);
|
|
269
289
|
if (node) applyToNode(tree, node, event);
|
|
270
290
|
}
|
|
271
291
|
|
|
272
|
-
export function buildTree(
|
|
273
|
-
|
|
292
|
+
export function buildTree(
|
|
293
|
+
scope: string,
|
|
294
|
+
events: WorkEvent[],
|
|
295
|
+
session?: string
|
|
296
|
+
): ScopeTree {
|
|
297
|
+
const tree: ScopeTree = {
|
|
298
|
+
scope,
|
|
299
|
+
active: null,
|
|
300
|
+
lanes: new Map(),
|
|
301
|
+
nodes: new Map(),
|
|
302
|
+
gitCursor: null,
|
|
303
|
+
};
|
|
274
304
|
for (const event of events) if (event.scope === scope) apply(tree, event);
|
|
305
|
+
tree.active = tree.lanes.get(session ?? "") ?? null;
|
|
275
306
|
return tree;
|
|
276
307
|
}
|
|
277
308
|
|
|
278
|
-
export function readTree(
|
|
279
|
-
|
|
309
|
+
export function readTree(
|
|
310
|
+
scope: string,
|
|
311
|
+
file: string = workTreeFile(),
|
|
312
|
+
session: string | undefined = workSession()
|
|
313
|
+
): ScopeTree {
|
|
314
|
+
return buildTree(scope, readEvents(file), session);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Nodes another session is working on right now. */
|
|
318
|
+
export function activeElsewhere(tree: ScopeTree): WorkNode[] {
|
|
319
|
+
return nodesWithStatus(tree, "active").filter((n) => n.id !== tree.active);
|
|
280
320
|
}
|
|
281
321
|
|
|
282
322
|
/** From the root down to the active node: the detours the user is inside of. */
|
|
@@ -131,9 +131,12 @@ const PALPlugin: Plugin = async ({ directory, client }: PluginInput) => {
|
|
|
131
131
|
|
|
132
132
|
return {
|
|
133
133
|
// --- Per-message: Inject dynamic system reminder ---
|
|
134
|
-
"experimental.chat.system.transform": async (
|
|
134
|
+
"experimental.chat.system.transform": async (input, output) => {
|
|
135
135
|
if (isPalSpawnedInference()) return;
|
|
136
|
-
const reminder = buildSystemReminder({
|
|
136
|
+
const reminder = buildSystemReminder({
|
|
137
|
+
agent: "opencode",
|
|
138
|
+
sessionId: input.sessionID,
|
|
139
|
+
});
|
|
137
140
|
if (reminder) output.system.push(reminder);
|
|
138
141
|
},
|
|
139
142
|
|
|
@@ -285,10 +288,11 @@ const PALPlugin: Plugin = async ({ directory, client }: PluginInput) => {
|
|
|
285
288
|
|
|
286
289
|
// --- Inject PAL_DIR into shell environment ---
|
|
287
290
|
"shell.env": async (
|
|
288
|
-
|
|
291
|
+
input: { cwd: string; sessionID?: string; callID?: string },
|
|
289
292
|
output: { env: Record<string, string> }
|
|
290
293
|
) => {
|
|
291
294
|
output.env.PAL_DIR = PAL_DIR;
|
|
295
|
+
if (input.sessionID) output.env.PAL_OPENCODE_SESSION_ID = input.sessionID;
|
|
292
296
|
},
|
|
293
297
|
};
|
|
294
298
|
};
|