one-code-extension 0.5.0 → 0.6.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 +1 -1
- package/docs/guide/differences-from-claude-code.md +9 -6
- package/docs/guide/installation.md +3 -4
- package/docs/guide/troubleshooting.md +1 -1
- package/extensions/auto-mode/denials.ts +3 -2
- package/extensions/background/cron.ts +127 -0
- package/extensions/background/index.ts +135 -47
- package/extensions/background/loop-fire.ts +39 -2
- package/extensions/background/switch-guard.ts +10 -6
- package/extensions/background/wakeup.ts +21 -2
- package/extensions/bash/index.ts +8 -30
- package/extensions/btw/index.ts +32 -18
- package/extensions/cache-marks/index.ts +42 -0
- package/extensions/claude-context/index.ts +157 -172
- package/extensions/compaction/index.ts +11 -8
- package/extensions/context-budget/budget.ts +16 -2
- package/extensions/context-budget/index.ts +15 -14
- package/extensions/context-management/index.ts +11 -28
- package/extensions/effort/index.ts +70 -7
- package/extensions/file-tracker/index.ts +111 -44
- package/extensions/file-tracker/replay.ts +76 -49
- package/extensions/file-tracker/restore.ts +12 -9
- package/extensions/file-tracker/tracker.ts +14 -1
- package/extensions/hooks/index.ts +60 -76
- package/extensions/hooks/prompt-context.ts +59 -0
- package/extensions/hooks/trust.ts +13 -7
- package/extensions/ledger/index.ts +46 -0
- package/extensions/ledger/wire.ts +154 -0
- package/extensions/lib/agent-cron.ts +17 -1
- package/extensions/lib/agent-loader.ts +3 -2
- package/extensions/lib/anthropic-auth.ts +98 -0
- package/extensions/lib/child-extensions.ts +14 -4
- package/extensions/lib/context-facts.ts +75 -56
- package/extensions/lib/covered-messages.ts +23 -0
- package/extensions/lib/deferred.ts +152 -12
- package/extensions/lib/ledger/fold-memo.ts +49 -0
- package/extensions/lib/ledger/fold.ts +265 -0
- package/extensions/lib/ledger/layout.ts +72 -0
- package/extensions/lib/ledger/plan.ts +102 -0
- package/extensions/lib/ledger/records.ts +276 -0
- package/extensions/lib/ledger/state.ts +47 -0
- package/extensions/lib/ledger/validate.ts +141 -0
- package/extensions/lib/ledger/wire.ts +632 -0
- package/extensions/lib/local-command.ts +24 -6
- package/extensions/lib/model-catalog/huggingface.json +5 -1
- package/extensions/lib/model-catalog/models-dev.json +10535 -10299
- package/extensions/lib/model-catalog/openrouter.json +191 -393
- package/extensions/lib/notifications.ts +57 -17
- package/extensions/lib/pi-version.ts +25 -8
- package/extensions/lib/plan-mode-channels.ts +29 -7
- package/extensions/lib/previous-mark.ts +101 -0
- package/extensions/lib/process-state.ts +48 -0
- package/extensions/lib/process-tree.ts +3 -2
- package/extensions/lib/reminders.ts +327 -621
- package/extensions/lib/request-replay.ts +186 -78
- package/extensions/lib/side-call.ts +11 -0
- package/extensions/lib/subagent-handback.ts +0 -1
- package/extensions/lib/tool-additions.ts +2 -22
- package/extensions/lib/turn-budget-layout.ts +18 -2
- package/extensions/lsp/index.ts +6 -8
- package/extensions/mcp/announce.ts +63 -98
- package/extensions/mcp/index.ts +51 -90
- package/extensions/mcp/trust.ts +13 -6
- package/extensions/model-default/index.ts +86 -19
- package/extensions/permissions/index.ts +157 -48
- package/extensions/permissions/modes.ts +5 -0
- package/extensions/plan-mode/index.ts +142 -45
- package/extensions/plan-mode/run-state.ts +26 -0
- package/extensions/plugins/index.ts +4 -1
- package/extensions/powershell/index.ts +2 -0
- package/extensions/recap/index.ts +11 -14
- package/extensions/search-tools/index.ts +2 -0
- package/extensions/skill/announce.ts +30 -0
- package/extensions/skill/index.ts +65 -48
- package/extensions/skill/invoke.ts +0 -67
- package/extensions/subagents/agents.ts +30 -8
- package/extensions/subagents/fork-cache.ts +9 -3
- package/extensions/subagents/index.ts +180 -107
- package/extensions/subagents/outcome.ts +3 -0
- package/extensions/subagents/parent-mode.ts +50 -0
- package/extensions/subagents/pending-claim.ts +18 -1
- package/extensions/subagents/runner.ts +29 -2
- package/extensions/subagents/runs.ts +5 -0
- package/extensions/system-prompt/index.ts +40 -6
- package/extensions/system-prompt/template.ts +10 -1
- package/extensions/system-reminder/index.ts +174 -202
- package/extensions/tool-search/index.ts +241 -167
- package/extensions/workflow/agent-session.ts +4 -1
- package/extensions/workflow/index.ts +16 -4
- package/extensions/workflow/run-manager.ts +4 -0
- package/extensions/worktree/index.ts +50 -14
- package/package.json +5 -2
- package/extensions/lib/context-stack.ts +0 -81
- package/extensions/subagents/announce.ts +0 -69
- package/extensions/tool-search/announce.ts +0 -58
|
@@ -31,10 +31,11 @@
|
|
|
31
31
|
*/
|
|
32
32
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
33
33
|
import { randomUUID } from "node:crypto";
|
|
34
|
-
import type
|
|
34
|
+
import { type ExtensionAPI, type ExtensionContext, VERSION as PI_VERSION } from "@earendil-works/pi-coding-agent";
|
|
35
35
|
import { withKeepAlive } from "../lsp/keep-alive.ts";
|
|
36
36
|
import { ONE_SHOT_COMMAND_FAILED_CHANNEL, RunOutcomeLatch } from "./interrupt.ts";
|
|
37
37
|
import { escapeXml } from "./local-command.ts";
|
|
38
|
+
import { piFiresBeforeAgentStartForCustomRuns } from "./pi-version.ts";
|
|
38
39
|
import { isUserRun, PROMPT_OPTIONS_CHANNEL } from "./prompt-options.ts";
|
|
39
40
|
import { wrapReminder } from "./reminders.ts";
|
|
40
41
|
|
|
@@ -481,11 +482,6 @@ export const NOTIFICATION_ID_KEY = "notificationId";
|
|
|
481
482
|
/** `details` key on a coalesced message: the `{customType, details}` of each notification it carries, in arrival order. */
|
|
482
483
|
export const NOTIFICATION_BATCH_KEY = "notifications";
|
|
483
484
|
|
|
484
|
-
/** True for the `details` of a message the notifier sent (one notification or a coalesced batch). */
|
|
485
|
-
export function isNotificationDetails(details: unknown): boolean {
|
|
486
|
-
return typeof details === "object" && details !== null && (NOTIFICATION_ID_KEY in details || NOTIFICATION_BATCH_KEY in details);
|
|
487
|
-
}
|
|
488
|
-
|
|
489
485
|
/** Inject a harness notification (a task/agent completion, a monitor event, a review note) into the conversation. */
|
|
490
486
|
export interface TaskNotifier {
|
|
491
487
|
(customType: string, text: string, details?: Record<string, unknown>): void;
|
|
@@ -533,6 +529,25 @@ export interface TaskNotifierOptions {
|
|
|
533
529
|
* report, so its hand-back would only repeat it.
|
|
534
530
|
*/
|
|
535
531
|
withdrawOnDelivery?: boolean;
|
|
532
|
+
/**
|
|
533
|
+
* Whether pi fires `before_agent_start` for a run `sendMessage` opens from
|
|
534
|
+
* idle (pi 1.2.0 and later). Then such a run builds One Code's system
|
|
535
|
+
* prompt like a typed one, and no notification needs the user-message
|
|
536
|
+
* path (see "First turn of a session"). Defaults to the running pi's
|
|
537
|
+
* version; tests set it to model an older pi.
|
|
538
|
+
*/
|
|
539
|
+
customRunsFireBeforeAgentStart?: boolean;
|
|
540
|
+
/**
|
|
541
|
+
* Runs once a notification this notifier sent reaches the conversation,
|
|
542
|
+
* with its customType and the details it was sent with: at its
|
|
543
|
+
* `message_end`, or at the send for one delivered as a user message (see
|
|
544
|
+
* "First turn of a session"), whose message carries no details for pi to
|
|
545
|
+
* hand back. A producer whose message makes something true for the model
|
|
546
|
+
* (a `/loop` fire's instructions) records that here, not when it calls the
|
|
547
|
+
* notifier: the notifier may still hold the message, and a `/reload`
|
|
548
|
+
* drops it.
|
|
549
|
+
*/
|
|
550
|
+
onDelivered?: (customType: string, details: Record<string, unknown>) => void;
|
|
536
551
|
}
|
|
537
552
|
|
|
538
553
|
/**
|
|
@@ -619,9 +634,10 @@ export const DEFAULT_COALESCE_MS = 250;
|
|
|
619
634
|
* anything dispatched afterwards; a throwing `sendMessage` is swallowed as a
|
|
620
635
|
* second line.
|
|
621
636
|
*
|
|
622
|
-
* First turn of a session: pi's idle
|
|
623
|
-
* calls `_runAgentPrompt` directly and
|
|
624
|
-
* `before_agent_start` fires — and our
|
|
637
|
+
* First turn of a session, on pi before 1.2.0: pi's idle
|
|
638
|
+
* `sendMessage(…, {triggerTurn: true})` calls `_runAgentPrompt` directly and
|
|
639
|
+
* skips the `prompt()` preamble, so no `before_agent_start` fires — and our
|
|
640
|
+
* system prompt is set from that hook.
|
|
625
641
|
* A session whose first input was `/loop …` therefore ran its first request on
|
|
626
642
|
* pi's stock system prompt with no reminder stack (review H1, measured). Until
|
|
627
643
|
* no `prompt()` has run in this process, an idle notification is delivered as a
|
|
@@ -636,6 +652,15 @@ export const DEFAULT_COALESCE_MS = 250;
|
|
|
636
652
|
* so the system-prompt extension installs the prompt for it from
|
|
637
653
|
* `context_with_system` (`system-prompt/idle-turn.ts`). Upstream ask:
|
|
638
654
|
* working-docs/upstream_prs.md #17.
|
|
655
|
+
*
|
|
656
|
+
* pi 1.2.0 fires `before_agent_start` for that run too, so there every
|
|
657
|
+
* notification goes the custom-message way from the start. Before, the
|
|
658
|
+
* fallback ran on every pi, and a `/reload` re-runs the factory, which starts
|
|
659
|
+
* over as if no prompt had run: a re-armed `/loop` wakeup went out as a plain
|
|
660
|
+
* user message and lost its customType and no-op fold (lifecycle grid
|
|
661
|
+
* R3-M17). On an older pi the fallback is still right after a reload: the
|
|
662
|
+
* system-prompt extension has lost the options too, and the user-message
|
|
663
|
+
* path is the only one that rebuilds the prompt.
|
|
639
664
|
*/
|
|
640
665
|
export function createTaskNotifier(pi: TaskNotifierApi, options: TaskNotifierOptions = {}): TaskNotifier {
|
|
641
666
|
interface Pending {
|
|
@@ -652,6 +677,7 @@ export function createTaskNotifier(pi: TaskNotifierApi, options: TaskNotifierOpt
|
|
|
652
677
|
details: Record<string, unknown>;
|
|
653
678
|
}
|
|
654
679
|
const coalesceMs = options.coalesceMs ?? DEFAULT_COALESCE_MS;
|
|
680
|
+
const customRunsPrompt = options.customRunsFireBeforeAgentStart ?? piFiresBeforeAgentStartForCustomRuns(PI_VERSION);
|
|
655
681
|
const pending = new Map<string, Pending>();
|
|
656
682
|
// Every notifier hears every message_end; simultaneous producers must not share an outbox id.
|
|
657
683
|
const producerId = randomUUID();
|
|
@@ -660,11 +686,12 @@ export function createTaskNotifier(pi: TaskNotifierApi, options: TaskNotifierOpt
|
|
|
660
686
|
/** False once the session this notifier belongs to has shut down. */
|
|
661
687
|
let active = true;
|
|
662
688
|
/**
|
|
663
|
-
* True once an idle turn can be built without `prompt()`:
|
|
664
|
-
*
|
|
665
|
-
* pi's prompt
|
|
689
|
+
* True once an idle turn can be built without `prompt()`: always on pi
|
|
690
|
+
* 1.2.0 and later; before it, once a prompt has run (the only path that
|
|
691
|
+
* emits before_agent_start there) or a command announced pi's prompt
|
|
692
|
+
* options.
|
|
666
693
|
*/
|
|
667
|
-
let prompted =
|
|
694
|
+
let prompted = customRunsPrompt;
|
|
668
695
|
/** True between agent_start and agent_settled. */
|
|
669
696
|
let busy = false;
|
|
670
697
|
/** True from a turn settling aborted/errored until the next turn starts: hold, do not start turns. */
|
|
@@ -698,6 +725,7 @@ export function createTaskNotifier(pi: TaskNotifierApi, options: TaskNotifierOpt
|
|
|
698
725
|
// followUp: pi 1.2.0 counts a starting run as busy and rejects a
|
|
699
726
|
// bare send there; when idle, the option changes nothing.
|
|
700
727
|
pi.sendUserMessage(frameForDelivery(entry.text, "opens-turn"), { deliverAs: "followUp" });
|
|
728
|
+
options.onDelivered?.(entry.customType, entry.details);
|
|
701
729
|
return;
|
|
702
730
|
}
|
|
703
731
|
pi.sendMessage(
|
|
@@ -805,10 +833,14 @@ export function createTaskNotifier(pi: TaskNotifierApi, options: TaskNotifierOpt
|
|
|
805
833
|
};
|
|
806
834
|
|
|
807
835
|
pi.on("message_end", (event) => {
|
|
808
|
-
const message = event.message as { role?: string; details?: unknown };
|
|
836
|
+
const message = event.message as { role?: string; customType?: string; details?: unknown };
|
|
809
837
|
if (message.role !== "custom") return;
|
|
810
|
-
const
|
|
811
|
-
|
|
838
|
+
const details = message.details as Record<string, unknown> | undefined;
|
|
839
|
+
const id = details?.[NOTIFICATION_ID_KEY];
|
|
840
|
+
if (typeof id !== "string") return;
|
|
841
|
+
pending.delete(id);
|
|
842
|
+
// Every notifier hears every message_end: only its own ids are its deliveries.
|
|
843
|
+
if (details && id.startsWith(`${producerId}-`)) options.onDelivered?.(message.customType ?? "", details);
|
|
812
844
|
});
|
|
813
845
|
pi.on("agent_end", (event, ctx) => {
|
|
814
846
|
outcome.record(event.messages, ctx.signal?.aborted);
|
|
@@ -847,7 +879,7 @@ export function createTaskNotifier(pi: TaskNotifierApi, options: TaskNotifierOpt
|
|
|
847
879
|
clearBatch();
|
|
848
880
|
held = [];
|
|
849
881
|
active = true;
|
|
850
|
-
prompted =
|
|
882
|
+
prompted = customRunsPrompt;
|
|
851
883
|
busy = false;
|
|
852
884
|
interrupted = false;
|
|
853
885
|
});
|
|
@@ -930,6 +962,14 @@ export function oneShotSessionNote(): string {
|
|
|
930
962
|
].join("\n");
|
|
931
963
|
}
|
|
932
964
|
|
|
965
|
+
/**
|
|
966
|
+
* The notice that a session whose first message said it was one-shot runs
|
|
967
|
+
* interactively now (a `-p` session continued in the TUI). One Code's own text.
|
|
968
|
+
*/
|
|
969
|
+
export function interactiveSessionNote(): string {
|
|
970
|
+
return "This session is interactive now, although its first message said it was one-shot: a user can reply, background work notifies you when it ends, scheduled jobs fire, and questions and plan approvals reach the user.";
|
|
971
|
+
}
|
|
972
|
+
|
|
933
973
|
function assertNever(mode: never): never {
|
|
934
974
|
throw new Error(`Unhandled session mode: ${String(mode)}`);
|
|
935
975
|
}
|
|
@@ -9,16 +9,16 @@
|
|
|
9
9
|
* never blocking — when outside it. The bundled `one-code` app pins pi inside
|
|
10
10
|
* the range, so it never warns.
|
|
11
11
|
*
|
|
12
|
-
* The minimum is
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
12
|
+
* The minimum is 1.0.0: the record-once request path (extensions/ledger)
|
|
13
|
+
* builds on pi 1.0's session entries, transcript system messages and request
|
|
14
|
+
* pipeline (decisions/distribution.md). The peer range in package.json and the
|
|
15
|
+
* guide name the same floor (pi-version.test.ts), and CI runs
|
|
16
|
+
* `test/e2e/pi-floor-smoke.mjs` against it.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
/** Inclusive minimum and exclusive maximum pi version this release is tested against. */
|
|
20
|
-
export const TESTED_PI_MIN = "0.
|
|
21
|
-
export const TESTED_PI_MAX_EXCLUSIVE = "1.
|
|
20
|
+
export const TESTED_PI_MIN = "1.0.0";
|
|
21
|
+
export const TESTED_PI_MAX_EXCLUSIVE = "1.5.0";
|
|
22
22
|
|
|
23
23
|
/** Dotted-numeric parse; undefined for anything that is not plain x.y.z numbers. */
|
|
24
24
|
export function parseVersion(version: string): number[] | undefined {
|
|
@@ -59,6 +59,18 @@ export function piVersionWarning(runningPiVersion: string | undefined): string |
|
|
|
59
59
|
);
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Whether the running pi fires `before_agent_start` for a run an extension
|
|
64
|
+
* opens from idle with `sendMessage(…, { triggerTurn: true })`. pi 1.2.0
|
|
65
|
+
* added it (its changelog, "before_agent_start for extension-triggered
|
|
66
|
+
* runs"); before, only `prompt()` fired the event. A version check, not a
|
|
67
|
+
* feature test: nothing tells the two apart before such a run starts, and an
|
|
68
|
+
* unparseable version reads as no, the older and safer behavior.
|
|
69
|
+
*/
|
|
70
|
+
export function piFiresBeforeAgentStartForCustomRuns(runningPiVersion: string | undefined): boolean {
|
|
71
|
+
return piAtLeast(runningPiVersion, "1.2.0");
|
|
72
|
+
}
|
|
73
|
+
|
|
62
74
|
/**
|
|
63
75
|
* Whether the running pi ships built-in extensions that can replace ours'
|
|
64
76
|
* names (`builtin:tool-search`, `builtin:mcp`; lib/replaced-builtins.mjs).
|
|
@@ -67,7 +79,12 @@ export function piVersionWarning(runningPiVersion: string | undefined): string |
|
|
|
67
79
|
* as no.
|
|
68
80
|
*/
|
|
69
81
|
export function piHasBuiltinExtensions(runningPiVersion: string | undefined, firstVersion: string): boolean {
|
|
82
|
+
return piAtLeast(runningPiVersion, firstVersion);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Whether the running pi is `min` or later; a missing or unparseable version reads as no. */
|
|
86
|
+
function piAtLeast(runningPiVersion: string | undefined, min: string): boolean {
|
|
70
87
|
if (!runningPiVersion) return false;
|
|
71
|
-
const order = compareVersions(runningPiVersion,
|
|
88
|
+
const order = compareVersions(runningPiVersion, min);
|
|
72
89
|
return order !== undefined && order >= 0;
|
|
73
90
|
}
|
|
@@ -11,16 +11,38 @@
|
|
|
11
11
|
export const MODE_CHANNEL = "one-code:set-permission-mode";
|
|
12
12
|
/** Announces plan mode's one writable file (`{ path }`); the permissions matcher consumes it. */
|
|
13
13
|
export const PLAN_FILE_CHANNEL = "one-code:plan-file-path";
|
|
14
|
-
/**
|
|
14
|
+
/**
|
|
15
|
+
* Session entry type persisting plan mode's allocated path across resume/branch
|
|
16
|
+
* (`{ path, sessionId?, copiedFrom? }`): the session that allocated it, and the
|
|
17
|
+
* plan a fork's copy was taken from.
|
|
18
|
+
*/
|
|
15
19
|
export const PLAN_FILE_ENTRY = "one-code:plan-mode-file";
|
|
16
20
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
21
|
+
export interface PlanFileRecord {
|
|
22
|
+
path: string;
|
|
23
|
+
sessionId?: string;
|
|
24
|
+
copiedFrom?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type EntryLike = { type: string; customType?: string; data?: unknown };
|
|
28
|
+
|
|
29
|
+
/** Every plan-file record in `entries`, oldest first. */
|
|
30
|
+
export function planFileRecords(entries: readonly EntryLike[]): PlanFileRecord[] {
|
|
31
|
+
const records: PlanFileRecord[] = [];
|
|
20
32
|
for (const entry of entries) {
|
|
21
33
|
if (entry.type !== "custom" || entry.customType !== PLAN_FILE_ENTRY) continue;
|
|
22
|
-
const
|
|
23
|
-
if (typeof
|
|
34
|
+
const data = entry.data as { path?: unknown; sessionId?: unknown; copiedFrom?: unknown } | undefined;
|
|
35
|
+
if (typeof data?.path !== "string") continue;
|
|
36
|
+
records.push({
|
|
37
|
+
path: data.path,
|
|
38
|
+
...(typeof data.sessionId === "string" ? { sessionId: data.sessionId } : {}),
|
|
39
|
+
...(typeof data.copiedFrom === "string" ? { copiedFrom: data.copiedFrom } : {}),
|
|
40
|
+
});
|
|
24
41
|
}
|
|
25
|
-
return
|
|
42
|
+
return records;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The plan file the branch's latest plan-file entry records, if any. */
|
|
46
|
+
export function planFileOnBranch(entries: readonly EntryLike[]): string | undefined {
|
|
47
|
+
return planFileRecords(entries).at(-1)?.path;
|
|
26
48
|
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keep the previous request's conversation breakpoint (pure).
|
|
3
|
+
*
|
|
4
|
+
* Anthropic finds an earlier cache entry only a limited way back from a
|
|
5
|
+
* breakpoint, about 20 content blocks, and pi moves the one conversation
|
|
6
|
+
* breakpoint to each request's tail. A request that adds many blocks since the
|
|
7
|
+
* last one (a large parallel tool batch), or a long run of `tool_addition`
|
|
8
|
+
* blocks after the session-facts system message, then cannot reach the entry
|
|
9
|
+
* the previous request wrote and re-caches the whole conversation (findings
|
|
10
|
+
* §72: a live pair read 11,707 of 24,837 tokens, and 24,837 with this mark).
|
|
11
|
+
* Marking the block the previous request marked again lets the server match
|
|
12
|
+
* that entry directly. It costs nothing: the entry exists, so the mark reads.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { appendPoint } from "./ledger/wire.ts";
|
|
16
|
+
import { MAX_CACHE_MARKERS } from "./request-replay.ts";
|
|
17
|
+
|
|
18
|
+
type Block = Record<string, unknown> & { cache_control?: unknown };
|
|
19
|
+
type Message = { role?: unknown; content?: unknown };
|
|
20
|
+
|
|
21
|
+
/** Where a request's last conversation breakpoint sat, and the block it marked. */
|
|
22
|
+
export interface MessageMark {
|
|
23
|
+
message: number;
|
|
24
|
+
block: number;
|
|
25
|
+
marker: unknown;
|
|
26
|
+
/** The marked block itself: pi builds each request afresh but reuses large strings (an image's data), so a structural compare short-circuits on them. */
|
|
27
|
+
content: Block;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const blocksOf = (message: unknown): Block[] =>
|
|
31
|
+
Array.isArray((message as Message | null)?.content) ? ((message as Message).content as Block[]) : [];
|
|
32
|
+
|
|
33
|
+
/** Equal JSON values, ignoring a top-level `cache_control`. */
|
|
34
|
+
function sameBlock(a: Block, b: Block): boolean {
|
|
35
|
+
const keys = (block: Block) => Object.keys(block).filter((key) => key !== "cache_control");
|
|
36
|
+
const left = keys(a);
|
|
37
|
+
return left.length === keys(b).length && left.every((key) => sameValue(a[key], b[key]));
|
|
38
|
+
}
|
|
39
|
+
function sameValue(a: unknown, b: unknown): boolean {
|
|
40
|
+
if (a === b) return true;
|
|
41
|
+
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null || Array.isArray(a) !== Array.isArray(b)) return false;
|
|
42
|
+
const left = Object.keys(a);
|
|
43
|
+
return left.length === Object.keys(b).length && left.every((key) => sameValue((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key]));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The last breakpoint in `messages`, or undefined when none carries one. */
|
|
47
|
+
export function lastMessageMark(messages: unknown): MessageMark | undefined {
|
|
48
|
+
if (!Array.isArray(messages)) return undefined;
|
|
49
|
+
for (let message = messages.length - 1; message >= 0; message--) {
|
|
50
|
+
const blocks = blocksOf(messages[message]);
|
|
51
|
+
for (let block = blocks.length - 1; block >= 0; block--) {
|
|
52
|
+
const marker = blocks[block]?.cache_control;
|
|
53
|
+
if (marker !== undefined) return { message, block, marker, content: blocks[block] };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function marksIn(value: unknown): number {
|
|
60
|
+
if (!Array.isArray(value)) return 0;
|
|
61
|
+
return value.reduce((sum: number, entry) => {
|
|
62
|
+
const own = (entry as Block | null)?.cache_control !== undefined ? 1 : 0;
|
|
63
|
+
return sum + own + blocksOf(entry).filter((block) => block?.cache_control !== undefined).length;
|
|
64
|
+
}, 0);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The payload with `previous` marked again: only when that block is still
|
|
69
|
+
* there with the same bytes, no later block already took its place as the
|
|
70
|
+
* tail mark, and a breakpoint slot is free. Otherwise the payload unchanged.
|
|
71
|
+
*/
|
|
72
|
+
export function withPreviousMark<T extends Record<string, unknown>>(payload: T, previous: MessageMark | undefined): T {
|
|
73
|
+
if (!previous || !Array.isArray(payload.messages)) return payload;
|
|
74
|
+
const messages = payload.messages as Message[];
|
|
75
|
+
const blocks = blocksOf(messages[previous.message]);
|
|
76
|
+
const target = blocks[previous.block];
|
|
77
|
+
if (!target || target.cache_control !== undefined || !sameBlock(target, previous.content)) return payload;
|
|
78
|
+
if (marksIn(payload.tools) + marksIn(payload.system) + marksIn(messages) >= MAX_CACHE_MARKERS) return payload;
|
|
79
|
+
const copy = [...messages];
|
|
80
|
+
const content = [...blocks];
|
|
81
|
+
content[previous.block] = { ...target, cache_control: previous.marker };
|
|
82
|
+
copy[previous.message] = { ...messages[previous.message], content };
|
|
83
|
+
return { ...payload, messages: copy };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The breakpoint the base request carried, located in `messages` (the request
|
|
88
|
+
* that extends it, spliced): the last block of the base's last element before
|
|
89
|
+
* its trailing directives, with the marker this request's own tail mark uses.
|
|
90
|
+
* Undefined when the request has no tail mark or the base is empty.
|
|
91
|
+
*/
|
|
92
|
+
export function baseMark(messages: unknown, baseList: readonly unknown[], api: string): MessageMark | undefined {
|
|
93
|
+
if (!Array.isArray(messages)) return undefined;
|
|
94
|
+
const end = appendPoint(baseList, api);
|
|
95
|
+
const tail = lastMessageMark(messages);
|
|
96
|
+
if (end === 0 || !tail || tail.message < end) return undefined;
|
|
97
|
+
const blocks = blocksOf(messages[end - 1]);
|
|
98
|
+
const block = blocks.length - 1;
|
|
99
|
+
if (block < 0) return undefined;
|
|
100
|
+
return { message: end - 1, block, marker: tail.marker, content: blocks[block] };
|
|
101
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process state: what lives as long as the One Code process and no longer
|
|
3
|
+
* (lifecycle-grid-hunt plan, part D). Every piece of state an extension holds
|
|
4
|
+
* is one of three kinds:
|
|
5
|
+
*
|
|
6
|
+
* - branch state follows the conversation: a resume, `/reload`, `/fork` and
|
|
7
|
+
* `/tree` each read it back from the branch (a `one-code:state` entry,
|
|
8
|
+
* lib/ledger/state.ts, or a fold of the branch's own entries);
|
|
9
|
+
* - process state survives `/reload` and the session swaps of `/clear`,
|
|
10
|
+
* `/new`, `/resume` and `/fork`, and ends with the process (a grant the
|
|
11
|
+
* user gave "for this session", a notice owed to the next session): this
|
|
12
|
+
* module;
|
|
13
|
+
* - resources (a timer, a child process, a connection) end with the instance
|
|
14
|
+
* that opened them, in `session_shutdown`.
|
|
15
|
+
*
|
|
16
|
+
* Module scope is none of them: pi re-evaluates every extension module at a
|
|
17
|
+
* `/reload` and at a `/resume` into another directory (findings §8), so a
|
|
18
|
+
* module-level value is lost there and kept everywhere else. The store here
|
|
19
|
+
* is a `globalThis` slot, which no re-evaluation resets.
|
|
20
|
+
*
|
|
21
|
+
* Pure: no pi imports.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const SLOT = Symbol.for("one-code:process-state");
|
|
25
|
+
|
|
26
|
+
type Store = Map<string, unknown>;
|
|
27
|
+
|
|
28
|
+
function store(): Store {
|
|
29
|
+
const slot = globalThis as { [SLOT]?: Store };
|
|
30
|
+
return (slot[SLOT] ??= new Map());
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The process's value for `key`, created by `init` on first use. Keys are
|
|
35
|
+
* namespaced by their owner (`"permissions:grants"`); the value is shared by
|
|
36
|
+
* every instance of that owner in the process, so it must be mutated, not
|
|
37
|
+
* replaced.
|
|
38
|
+
*/
|
|
39
|
+
export function processState<T>(key: string, init: () => T): T {
|
|
40
|
+
const values = store();
|
|
41
|
+
if (!values.has(key)) values.set(key, init());
|
|
42
|
+
return values.get(key) as T;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Forget every process value: a test's fresh process. */
|
|
46
|
+
export function resetProcessStateForTest(): void {
|
|
47
|
+
store().clear();
|
|
48
|
+
}
|
|
@@ -165,8 +165,9 @@ function terminateRememberedGroups(): void {
|
|
|
165
165
|
* Remember a detached background tree from spawn until its group becomes empty.
|
|
166
166
|
* Register before awaiting its leader, so even immediate process exit covers
|
|
167
167
|
* running work. The unref'd reaper cannot keep a print run alive. No session
|
|
168
|
-
* shutdown handler touches this set; process listeners keep
|
|
169
|
-
* alive
|
|
168
|
+
* shutdown handler touches this set; the process listeners keep this module
|
|
169
|
+
* instance alive, and reach its set, even after pi evaluates the module again
|
|
170
|
+
* (a /reload, a /resume into another directory: findings §8).
|
|
170
171
|
*/
|
|
171
172
|
export function rememberProcessGroup(child: ChildProcess): void {
|
|
172
173
|
if (child.pid == null || rememberedGroups.has(child)) return;
|