@timurproko/a1 0.1.8-dev.459 → 0.1.8-dev.467

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.
@@ -126,6 +126,8 @@ export interface OwnedUiFooterView {
126
126
  export interface OwnedUiStatusView {
127
127
  readonly title: string;
128
128
  readonly workingMessage: string | null;
129
+ /** Estimated progress of the shown work state as an integer percent below 100, when the engine can measure it. */
130
+ readonly workingProgress?: number | null;
129
131
  readonly diagnostics: readonly string[];
130
132
  readonly badges: readonly string[];
131
133
  readonly usage?: OwnedUiUsageView;
@@ -347,6 +347,9 @@ export function assertOwnedUiEditorState(editor) {
347
347
  export function assertOwnedUiStatusView(status) {
348
348
  assertBoundedText(status.title, "owned-UI status title", MAX_LABEL_LENGTH);
349
349
  assertOptionalText(status.workingMessage, "owned-UI working message", MAX_MESSAGE_LENGTH);
350
+ if (status.workingProgress !== undefined && status.workingProgress !== null) {
351
+ assertIntegerInRange(status.workingProgress, 0, 99, "owned-UI working progress");
352
+ }
350
353
  assertCollection(status.diagnostics, "owned-UI status diagnostics", MAX_STATUS_DIAGNOSTICS);
351
354
  for (const diagnostic of status.diagnostics) {
352
355
  assertBoundedText(diagnostic, "owned-UI status diagnostic", MAX_MESSAGE_LENGTH);
@@ -75,19 +75,29 @@ export function createPiShellStatus(view, formatProgressStatus, runtime) {
75
75
  const statusUi = createTuiFacade(runtime ?? { getColumns: () => 80, getRows: () => 24, requestRender() { } });
76
76
  let workingOverride;
77
77
  let outputPad = PINNED_PI_LAYOUT.outputPad;
78
+ let progressPresentation = "pinned";
78
79
  let placement = statusPlacement(view, workingOverride);
79
- let component = statusComponent(view, statusUi, workingOverride, outputPad, formatProgressStatus, placement);
80
- let signature = statusSignature(view, workingOverride, outputPad, placement);
80
+ const liveStatusText = () => formatProgressStatus(liveWorkingText(view, workingOverride, progressPresentation));
81
+ let component = statusComponent(view, statusUi, outputPad, liveStatusText, placement);
82
+ let signature = statusSignature(view, workingOverride, outputPad, placement, progressPresentation);
81
83
  const rebuild = () => {
82
84
  const nextPlacement = statusPlacement(view, workingOverride);
83
- const nextSignature = statusSignature(view, workingOverride, outputPad, nextPlacement);
85
+ const nextSignature = statusSignature(view, workingOverride, outputPad, nextPlacement, progressPresentation);
84
86
  if (nextSignature === signature)
85
87
  return;
88
+ // Performance: progress ticks change only the live message; the spinner keeps its frame and timer.
89
+ if (placement === "live" && nextPlacement === "live" && component instanceof WorkingStatusIndicator
90
+ && statusSignature(view, workingOverride, outputPad, nextPlacement, progressPresentation, false)
91
+ === statusSignature(view, workingOverride, outputPad, placement, progressPresentation, false)) {
92
+ component.setMessage(liveStatusText());
93
+ signature = nextSignature;
94
+ return;
95
+ }
86
96
  if (component !== undefined && "dispose" in component && typeof component.dispose === "function")
87
97
  component.dispose();
88
98
  placement = nextPlacement;
89
99
  signature = nextSignature;
90
- component = statusComponent(view, statusUi, workingOverride, outputPad, formatProgressStatus, placement);
100
+ component = statusComponent(view, statusUi, outputPad, liveStatusText, placement);
91
101
  };
92
102
  return {
93
103
  render: width => component?.render(width) ?? [],
@@ -107,6 +117,10 @@ export function createPiShellStatus(view, formatProgressStatus, runtime) {
107
117
  outputPad = padding;
108
118
  rebuild();
109
119
  },
120
+ setProgressPresentation(presentation) {
121
+ progressPresentation = presentation;
122
+ rebuild();
123
+ },
110
124
  dispose() {
111
125
  if (component !== undefined && "dispose" in component && typeof component.dispose === "function")
112
126
  component.dispose();
@@ -145,9 +159,18 @@ function statusPlacement(view, workingOverride) {
145
159
  return "dock";
146
160
  return view.status.workingMessage === null ? "hidden" : "dock";
147
161
  }
148
- function statusComponent(view, ui, workingOverride, outputPad, formatProgressStatus, placement) {
162
+ // Rationale: the engine publishes the semantic working word and a separate measured percent; bare A1
163
+ // composes them here, while the pinned route keeps the bare word for comparison with Pi.
164
+ function liveWorkingText(view, workingOverride, progressPresentation) {
165
+ const message = workingOverride ?? view.status.workingMessage ?? "Working";
166
+ const progress = view.status.workingProgress;
167
+ return workingOverride === undefined && progressPresentation === "custom-viewport" && typeof progress === "number"
168
+ ? `${message} (${progress}%)`
169
+ : message;
170
+ }
171
+ function statusComponent(view, ui, outputPad, liveStatusText, placement) {
149
172
  if (placement === "live") {
150
- return new WorkingStatusIndicator(ui, formatProgressStatus(workingOverride ?? view.status.workingMessage ?? "Working"));
173
+ return new WorkingStatusIndicator(ui, liveStatusText());
151
174
  }
152
175
  if (placement === "dock") {
153
176
  if (view.lifecycle === "failed") {
@@ -157,8 +180,9 @@ function statusComponent(view, ui, workingOverride, outputPad, formatProgressSta
157
180
  }
158
181
  return undefined;
159
182
  }
160
- function statusSignature(view, workingOverride, outputPad, placement) {
161
- return `${placement}\u0000${outputPad}\u0000${view.lifecycle}\u0000${workingOverride ?? ""}\u0000${view.status.workingMessage ?? ""}\u0000${view.status.diagnostics.at(-1) ?? ""}`;
183
+ function statusSignature(view, workingOverride, outputPad, placement, progressPresentation, withMessage = true) {
184
+ const message = withMessage ? `${workingOverride ?? ""}\u0000${view.status.workingMessage ?? ""}\u0000${view.status.workingProgress ?? ""}` : "";
185
+ return `${placement}\u0000${outputPad}\u0000${view.lifecycle}\u0000${progressPresentation}\u0000${message}\u0000${view.status.diagnostics.at(-1) ?? ""}`;
162
186
  }
163
187
  function queuedInputText(submissions, presentation) {
164
188
  if (submissions.length === 0)
@@ -79,6 +79,8 @@ export type PiShellStatusPlacement = "live" | "dock" | "hidden";
79
79
  export interface PiShellStatusPort extends PiShellViewComponentPort {
80
80
  setWorkingOverride(message: string | undefined): void;
81
81
  setOutputPad(padding: 0 | 1): void;
82
+ /** Bare A1 shows engine-measured progress beside the working word; the pinned route keeps the bare word. */
83
+ setProgressPresentation(presentation: "pinned" | "custom-viewport"): void;
82
84
  /** Semantic row placement; callers must not inspect rendered text. */
83
85
  placement(): PiShellStatusPlacement;
84
86
  renderDock(width: number): readonly string[];
@@ -14,6 +14,7 @@ import { OWNED_UI_EXTENSION_CONTRACT_VERSION, OWNED_UI_EXTENSION_RENDER_CALLBACK
14
14
  import { PINNED_PI_SETTINGS_CALLBACKS, PINNED_PI_WORKFLOW_COMMAND_NAMES, } from "./workflows.js";
15
15
  import { createPiRuntimeIntegration } from "./runtime-integration.js";
16
16
  import { PiSessionCommandIntegration } from "./session-integration.js";
17
+ import { observeCompactionProgress } from "./compaction-progress.js";
17
18
  import { PiSettingsIntegration } from "./settings-integration.js";
18
19
  import { PendingEngineDelivery } from "./pending-delivery.js";
19
20
  /** Explicit flush failure when required delivery was interrupted rather than completed. */
@@ -147,6 +148,7 @@ export class PiEngineAdapter {
147
148
  #assistantResponseSequence = 0;
148
149
  #statusKind = null;
149
150
  #sessionCommands;
151
+ #compactionProgress = null;
150
152
  #gitBranch = null;
151
153
  #extensionUi;
152
154
  #extensionShutdown;
@@ -1059,6 +1061,7 @@ export class PiEngineAdapter {
1059
1061
  clearQueuedWorkflows() {
1060
1062
  const session = this.#requireWorkflowSession();
1061
1063
  const result = session.clearQueue?.();
1064
+ this.#sessionCommands?.forgetQueuedImages();
1062
1065
  if (!isRecord(result))
1063
1066
  return [];
1064
1067
  return [...readStringArray(result.steering), ...readStringArray(result.followUp)];
@@ -1257,6 +1260,8 @@ export class PiEngineAdapter {
1257
1260
  this.#emitEvent({ type: "session-lifecycle", lifecycle: "stopping", reason: null });
1258
1261
  this.#unsubscribe?.();
1259
1262
  this.#unsubscribe = undefined;
1263
+ this.#compactionProgress?.dispose();
1264
+ this.#compactionProgress = null;
1260
1265
  await this.#runtime?.dispose();
1261
1266
  this.#lifecycle = "stopped";
1262
1267
  this.#emitEvent({ type: "session-lifecycle", lifecycle: "stopped", reason: null });
@@ -2016,6 +2021,11 @@ export class PiEngineAdapter {
2016
2021
  this.#activeCommandIds = [];
2017
2022
  this.#completedCommands.clear();
2018
2023
  this.#sessionCommands = new PiSessionCommandIntegration(session);
2024
+ this.#compactionProgress?.dispose();
2025
+ this.#compactionProgress = observeCompactionProgress(session, percent => {
2026
+ if (this.#session === session && !this.#disposed)
2027
+ this.#publishCompactionProgress(percent);
2028
+ });
2019
2029
  this.#editor = {
2020
2030
  text: "",
2021
2031
  queuedSubmissions: [],
@@ -2024,7 +2034,7 @@ export class PiEngineAdapter {
2024
2034
  historyRevision: this.#editor.historyRevision + 1,
2025
2035
  submitEnabled: true,
2026
2036
  };
2027
- this.#status = { ...this.#status, workingMessage: null, badges: [] };
2037
+ this.#status = { ...this.#status, workingMessage: null, workingProgress: null, badges: [] };
2028
2038
  this.#statusKind = null;
2029
2039
  this.#agentRunActive = false;
2030
2040
  this.#agentRunSequence = 0;
@@ -2115,7 +2125,7 @@ export class PiEngineAdapter {
2115
2125
  const wasBusy = this.#lifecycle === "busy";
2116
2126
  this.#statusKind = kind;
2117
2127
  this.#lifecycle = "busy";
2118
- this.#status = { ...this.#status, workingMessage: message };
2128
+ this.#status = { ...this.#status, workingMessage: message, workingProgress: null };
2119
2129
  if (!wasBusy)
2120
2130
  this.#emitEvent({ type: "session-lifecycle", lifecycle: "busy", reason: null });
2121
2131
  this.#emitEvent({ type: "status", status: this.#status });
@@ -2133,10 +2143,32 @@ export class PiEngineAdapter {
2133
2143
  #leaveWorkStates() {
2134
2144
  this.#statusKind = null;
2135
2145
  this.#lifecycle = "ready";
2136
- this.#status = { ...this.#status, workingMessage: null };
2146
+ this.#status = { ...this.#status, workingMessage: null, workingProgress: null };
2137
2147
  this.#emitEvent({ type: "session-lifecycle", lifecycle: "ready", reason: null });
2138
2148
  this.#emitEvent({ type: "status", status: this.#status });
2139
2149
  }
2150
+ // Invariant: progress belongs to the compaction state only and is published when the integer changes.
2151
+ #publishCompactionProgress(percent) {
2152
+ if (this.#statusKind !== "compaction" || this.#status.workingProgress === percent)
2153
+ return;
2154
+ this.#status = { ...this.#status, workingProgress: percent };
2155
+ this.#emitEvent({ type: "status", status: this.#status });
2156
+ }
2157
+ // Rationale: a manual compaction ends with an idle session, so the engine would never consume
2158
+ // the messages queued during it; automatic compaction returns to a run or a pending prompt.
2159
+ #deliverQueuedAfterCompaction() {
2160
+ const commands = this.#sessionCommands;
2161
+ const generation = this.#sessionGeneration;
2162
+ if (commands === undefined)
2163
+ return;
2164
+ const report = (error) => {
2165
+ if (generation !== this.#sessionGeneration || this.#disposed)
2166
+ return;
2167
+ this.#addDiagnostic("error", "engine-command", `Queued input could not be sent after compaction: ${error instanceof Error ? error.message : String(error)}`, true);
2168
+ this.#emitView();
2169
+ };
2170
+ commands.deliverQueuedAfterCompaction(report).catch(report);
2171
+ }
2140
2172
  #handlePiEvent(event) {
2141
2173
  try {
2142
2174
  this.#applyPiEvent(event);
@@ -2286,9 +2318,13 @@ export class PiEngineAdapter {
2286
2318
  return;
2287
2319
  case "compaction_start":
2288
2320
  this.#enterWorkState("compaction", "Compacting");
2321
+ this.#compactionProgress?.begin();
2289
2322
  return;
2290
2323
  case "compaction_end":
2324
+ this.#compactionProgress?.end();
2291
2325
  this.#endWorkState("compaction");
2326
+ if (event.reason === "manual")
2327
+ this.#deliverQueuedAfterCompaction();
2292
2328
  return;
2293
2329
  case "thinking_level_changed":
2294
2330
  this.#thinkingLevel = readThinkingLevel(event.level);
@@ -2797,10 +2833,12 @@ export class PiEngineAdapter {
2797
2833
  this.#admissionStopped = !canResume;
2798
2834
  this.#unsubscribe?.();
2799
2835
  this.#unsubscribe = undefined;
2836
+ this.#compactionProgress?.dispose();
2837
+ this.#compactionProgress = null;
2800
2838
  ++this.#sessionGeneration;
2801
2839
  this.#agentRunActive = false;
2802
2840
  this.#statusKind = null;
2803
- this.#status = { ...this.#status, workingMessage: null };
2841
+ this.#status = { ...this.#status, workingMessage: null, workingProgress: null };
2804
2842
  this.#lifecycle = this.#disposed ? "stopped" : canResume ? "ready" : "failed";
2805
2843
  this.#editor = { ...this.#editor, submitEnabled: canResume && !this.#disposed };
2806
2844
  this.#viewRevision += 1;
@@ -0,0 +1,28 @@
1
+ /** Expected summary size for a first compaction, when no previous summary on the branch can serve as the estimate. */
2
+ export declare const DEFAULT_EXPECTED_COMPACTION_SUMMARY_CHARS = 4000;
3
+ /** The public session surface compaction progress reads: the agent's stream function and the branch entries. */
4
+ export interface CompactionProgressSession {
5
+ readonly agent?: {
6
+ streamFunction?: unknown;
7
+ } | undefined;
8
+ readonly sessionManager?: {
9
+ getBranch?(): readonly unknown[];
10
+ } | undefined;
11
+ }
12
+ export interface CompactionProgressObserver {
13
+ /** Marks `compaction_start`: resets the streamed count and captures the expected summary size. */
14
+ begin(): void;
15
+ /** Marks `compaction_end`: later stream chunks are ignored. */
16
+ end(): void;
17
+ /** Restores the original stream function; the observer reports nothing afterwards. */
18
+ dispose(): void;
19
+ }
20
+ /**
21
+ * Observes the summarization stream of a compaction through the session agent's public stream
22
+ * function and reports an estimated integer percent. Pi never iterates that stream itself (it
23
+ * reads only its final result), so the observer iterates it in the background between
24
+ * `begin()` and `end()` and returns the same stream object to Pi. Outside that window the
25
+ * wrapper is a pass-through. Returns null when the agent exposes no callable stream function,
26
+ * in which case compaction proceeds without progress.
27
+ */
28
+ export declare function observeCompactionProgress(session: CompactionProgressSession, onProgress: (percent: number) => void): CompactionProgressObserver | null;
@@ -0,0 +1,96 @@
1
+ /** Expected summary size for a first compaction, when no previous summary on the branch can serve as the estimate. */
2
+ export const DEFAULT_EXPECTED_COMPACTION_SUMMARY_CHARS = 4000;
3
+ /** The percent shown while a compaction is still running never reaches this value. */
4
+ const MAX_RUNNING_PERCENT = 99;
5
+ /**
6
+ * Observes the summarization stream of a compaction through the session agent's public stream
7
+ * function and reports an estimated integer percent. Pi never iterates that stream itself (it
8
+ * reads only its final result), so the observer iterates it in the background between
9
+ * `begin()` and `end()` and returns the same stream object to Pi. Outside that window the
10
+ * wrapper is a pass-through. Returns null when the agent exposes no callable stream function,
11
+ * in which case compaction proceeds without progress.
12
+ */
13
+ export function observeCompactionProgress(session, onProgress) {
14
+ const agent = session.agent;
15
+ if (agent === undefined || typeof agent.streamFunction !== "function")
16
+ return null;
17
+ const original = agent.streamFunction;
18
+ let active = false;
19
+ let disposed = false;
20
+ let generation = 0;
21
+ let streamed = 0;
22
+ let expected = DEFAULT_EXPECTED_COMPACTION_SUMMARY_CHARS;
23
+ let reported = null;
24
+ const report = () => {
25
+ const percent = Math.min(MAX_RUNNING_PERCENT, Math.floor((100 * streamed) / expected));
26
+ if (percent === reported)
27
+ return;
28
+ reported = percent;
29
+ onProgress(percent);
30
+ };
31
+ const wrapped = async (model, context, options) => {
32
+ const stream = await original(model, context, options);
33
+ if (!active || disposed)
34
+ return stream;
35
+ const observed = generation;
36
+ void (async () => {
37
+ report();
38
+ // Invariant: observation never affects the request; a stream that cannot be iterated ends observation only.
39
+ try {
40
+ for await (const event of stream) {
41
+ if (observed !== generation || !active)
42
+ return;
43
+ if (event.type === "text_delta") {
44
+ streamed += event.delta.length;
45
+ report();
46
+ }
47
+ }
48
+ }
49
+ catch {
50
+ return;
51
+ }
52
+ })();
53
+ return stream;
54
+ };
55
+ agent.streamFunction = wrapped;
56
+ return {
57
+ begin() {
58
+ generation += 1;
59
+ streamed = 0;
60
+ reported = null;
61
+ expected = latestCompactionSummaryLength(session) ?? DEFAULT_EXPECTED_COMPACTION_SUMMARY_CHARS;
62
+ active = true;
63
+ },
64
+ end() {
65
+ active = false;
66
+ generation += 1;
67
+ },
68
+ dispose() {
69
+ if (disposed)
70
+ return;
71
+ disposed = true;
72
+ active = false;
73
+ generation += 1;
74
+ if (agent.streamFunction === wrapped)
75
+ agent.streamFunction = original;
76
+ },
77
+ };
78
+ }
79
+ function latestCompactionSummaryLength(session) {
80
+ let entries;
81
+ try {
82
+ entries = session.sessionManager?.getBranch?.() ?? [];
83
+ }
84
+ catch {
85
+ return null;
86
+ }
87
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
88
+ const entry = entries[index];
89
+ if (typeof entry !== "object" || entry === null)
90
+ continue;
91
+ const { type, summary } = entry;
92
+ if (type === "compaction" && typeof summary === "string" && summary.length > 0)
93
+ return summary.length;
94
+ }
95
+ return null;
96
+ }
@@ -6,7 +6,7 @@ export interface PiCapabilityConformanceResult {
6
6
  export declare const REQUIRED_PI_CAPABILITY_OPERATIONS: Readonly<{
7
7
  readonly "public-exports": readonly ["services.create", "session.create", "runtime.create"];
8
8
  readonly "session-lifecycle": readonly ["session.new", "session.resume", "session.rebind", "session.dispose"];
9
- readonly "commands-events": readonly ["prompt", "steer", "followUp", "abort", "compact", "setModel", "setThinkingLevel", "subscribe", "dispose"];
9
+ readonly "commands-events": readonly ["prompt", "steer", "followUp", "clearQueue", "abort", "compact", "setModel", "setThinkingLevel", "subscribe", "dispose"];
10
10
  readonly "models-authentication": readonly ["models.list", "models.refresh", "models.completeSimple", "auth.status", "auth.login", "auth.logout", "auth.cancel"];
11
11
  readonly settings: readonly ["settings.read", "settings.write", "settings.flush"];
12
12
  readonly "resources-extensions": readonly ["resources.discover", "extensions.inline", "extensions.bind", "extensions.reload", "renderers.invoke"];
@@ -8,7 +8,7 @@ import { createWindowsNulCleanupExtension } from "./windows-filesystem-hygiene.j
8
8
  export const REQUIRED_PI_CAPABILITY_OPERATIONS = Object.freeze({
9
9
  "public-exports": ["services.create", "session.create", "runtime.create"],
10
10
  "session-lifecycle": ["session.new", "session.resume", "session.rebind", "session.dispose"],
11
- "commands-events": ["prompt", "steer", "followUp", "abort", "compact", "setModel", "setThinkingLevel", "subscribe", "dispose"],
11
+ "commands-events": ["prompt", "steer", "followUp", "clearQueue", "abort", "compact", "setModel", "setThinkingLevel", "subscribe", "dispose"],
12
12
  "models-authentication": ["models.list", "models.refresh", "models.completeSimple", "auth.status", "auth.login", "auth.logout", "auth.cancel"],
13
13
  settings: ["settings.read", "settings.write", "settings.flush"],
14
14
  "resources-extensions": ["resources.discover", "extensions.inline", "extensions.bind", "extensions.reload", "renderers.invoke"],
@@ -85,7 +85,7 @@ export async function runPiUpgradeConformance() {
85
85
  });
86
86
  const session = created.session;
87
87
  sessionId = session.sessionId;
88
- requireMethods(session, "session commands", ["prompt", "steer", "followUp", "abort", "compact", "setModel", "setThinkingLevel", "subscribe", "dispose"]);
88
+ requireMethods(session, "session commands", ["prompt", "steer", "followUp", "clearQueue", "abort", "compact", "setModel", "setThinkingLevel", "subscribe", "dispose"]);
89
89
  requireMethods(services.modelRuntime, "models/authentication", ["getModels", "getModel", "completeSimple", "checkAuth", "login", "logout", "refresh"]);
90
90
  requireMethods(services.settingsManager, "settings", ["getGlobalSettings", "getProjectSettings", "flush"]);
91
91
  requireMethods(services.resourceLoader, "resources/extensions", ["getExtensions", "getSkills", "getPrompts", "getThemes", "reload"]);
@@ -96,7 +96,7 @@ export async function runPiUpgradeConformance() {
96
96
  catch (error) {
97
97
  throw new PiUpgradeConformanceError("session", error);
98
98
  }
99
- const commandSurface = ["prompt", "steer", "followUp", "abort", "compact", "setModel", "setThinkingLevel", "subscribe", "dispose"];
99
+ const commandSurface = ["prompt", "steer", "followUp", "clearQueue", "abort", "compact", "setModel", "setThinkingLevel", "subscribe", "dispose"];
100
100
  const capabilities = Object.entries(REQUIRED_PI_CAPABILITY_OPERATIONS)
101
101
  .map(([capabilityName, operations]) => capability(capabilityName, operations));
102
102
  validatePiCapabilityResults(VERSION, capabilities);
@@ -1,24 +1,33 @@
1
1
  import type { AgentSession, AgentSessionEvent, PromptOptions } from "../startup-public.js";
2
2
  import { type AgentCommandOutcome, type AgentEvent } from "../../../contracts/agent-engine/index.js";
3
+ type PiPromptImages = NonNullable<PromptOptions["images"]>;
3
4
  export interface PiDocumentedSessionCommands {
4
5
  readonly isStreaming: AgentSession["isStreaming"];
5
6
  readonly isRetrying: AgentSession["isRetrying"];
6
7
  readonly isCompacting: AgentSession["isCompacting"];
7
8
  prompt(text: string, options?: Parameters<AgentSession["prompt"]>[1]): Promise<void>;
8
- steer(text: string): Promise<void>;
9
- followUp(text: string): Promise<void>;
9
+ steer(text: string, images?: PiPromptImages): Promise<void>;
10
+ followUp(text: string, images?: PiPromptImages): Promise<void>;
10
11
  abort(): Promise<void>;
11
12
  abortRetry(): void;
12
13
  abortCompaction(): void;
13
14
  compact(customInstructions?: Parameters<AgentSession["compact"]>[0]): Promise<unknown>;
15
+ clearQueue(): {
16
+ readonly steering: readonly string[];
17
+ readonly followUp: readonly string[];
18
+ };
14
19
  executeBash?(command: string, onChunk: unknown, options: {
15
20
  readonly excludeFromContext: boolean;
16
21
  }): Promise<unknown>;
22
+ /** Optional: extension commands cannot be queued, so they run immediately during compaction as in pinned Pi. */
23
+ readonly extensionRunner?: {
24
+ getCommand(name: string): unknown;
25
+ } | undefined;
17
26
  }
18
27
  export type PiSessionCommand = {
19
28
  readonly type: "prompt" | "steer" | "follow-up";
20
29
  readonly text: string;
21
- readonly images?: NonNullable<PromptOptions["images"]>;
30
+ readonly images?: PiPromptImages;
22
31
  } | {
23
32
  readonly type: "abort" | "retry" | "compact";
24
33
  } | {
@@ -37,9 +46,22 @@ export declare class PiSessionCommandIntegration {
37
46
  private readonly session;
38
47
  constructor(session: PiDocumentedSessionCommands);
39
48
  execute(command: PiSessionCommand): Promise<PiSessionCommandResult>;
49
+ /**
50
+ * Delivers the messages queued during a manual compaction. The session is idle when a manual
51
+ * compaction ends, so nothing would consume the queue: the first message starts one run
52
+ * through the ordinary prompt path with its mode, and the rest are re-queued for that run,
53
+ * whose initial poll injects them in order. Automatic compaction needs no delivery: its
54
+ * continuing run or pending prompt consumes the queue itself. The run is not awaited; when
55
+ * the start is refused before the message is accepted, the queue is restored and the error
56
+ * reported through `onFailure`.
57
+ */
58
+ deliverQueuedAfterCompaction(onFailure: (error: unknown) => void): Promise<void>;
59
+ /** Forgets the attachments kept for queued messages; the queue itself was cleared by the caller. */
60
+ forgetQueuedImages(): void;
40
61
  }
41
62
  export interface PiOrderedEventIntegration {
42
63
  dispose(): void;
43
64
  }
44
65
  export declare function subscribeToPiSessionEvents(session: Pick<AgentSession, "subscribe">, sessionId: string, emit: (event: AgentEvent) => void, malformed: (diagnostic: string) => void): PiOrderedEventIntegration;
45
66
  export declare function convertPiSessionEvent(event: AgentSessionEvent, sessionId: string, sequence: number): AgentEvent | null;
67
+ export {};
@@ -3,6 +3,9 @@ import { AGENT_ENGINE_CONTRACT_VERSION, } from "../../../contracts/agent-engine/
3
3
  export class PiSessionCommandIntegration {
4
4
  session;
5
5
  #lastPrompt = null;
6
+ // Rationale: Pi's queue restore returns text only, so the attachments of messages queued
7
+ // during compaction are kept here until the queue is delivered or cleared.
8
+ #queuedImages = [];
6
9
  constructor(session) {
7
10
  this.session = session;
8
11
  }
@@ -18,22 +21,24 @@ export class PiSessionCommandIntegration {
18
21
  });
19
22
  return { outcome: "completed" };
20
23
  case "steer":
24
+ case "follow-up": {
21
25
  this.#lastPrompt = command.text;
26
+ const mode = command.type === "steer" ? "steer" : "followUp";
27
+ if (this.session.isCompacting && !this.#isExtensionCommand(command.text)) {
28
+ // Compatibility: match interactive Pi: prompt() refuses input during manual compaction,
29
+ // while the engine queue accepts it at any time and delivers it when compaction ends.
30
+ await this.#queue(mode, command.text, command.images);
31
+ return { outcome: "completed" };
32
+ }
22
33
  // Compatibility: match interactive Pi: prompt() owns template/extension expansion and
23
34
  // turns the accepted steering message into the visible user row while
24
35
  // later messages remain in the pending queue.
25
36
  await this.session.prompt(command.text, {
26
- streamingBehavior: "steer",
27
- ...(command.images === undefined ? {} : { images: [...command.images] }),
28
- });
29
- return { outcome: "completed" };
30
- case "follow-up":
31
- this.#lastPrompt = command.text;
32
- await this.session.prompt(command.text, {
33
- streamingBehavior: "followUp",
37
+ streamingBehavior: mode,
34
38
  ...(command.images === undefined ? {} : { images: [...command.images] }),
35
39
  });
36
40
  return { outcome: "completed" };
41
+ }
37
42
  case "abort":
38
43
  if (this.session.isRetrying)
39
44
  this.session.abortRetry();
@@ -61,6 +66,86 @@ export class PiSessionCommandIntegration {
61
66
  }
62
67
  }
63
68
  }
69
+ /**
70
+ * Delivers the messages queued during a manual compaction. The session is idle when a manual
71
+ * compaction ends, so nothing would consume the queue: the first message starts one run
72
+ * through the ordinary prompt path with its mode, and the rest are re-queued for that run,
73
+ * whose initial poll injects them in order. Automatic compaction needs no delivery: its
74
+ * continuing run or pending prompt consumes the queue itself. The run is not awaited; when
75
+ * the start is refused before the message is accepted, the queue is restored and the error
76
+ * reported through `onFailure`.
77
+ */
78
+ async deliverQueuedAfterCompaction(onFailure) {
79
+ if (this.session.isStreaming || this.session.isCompacting)
80
+ return;
81
+ const { steering, followUp } = this.session.clearQueue();
82
+ const queued = [
83
+ ...steering.map(text => ({ mode: "steer", text, images: this.#takeImages(text) })),
84
+ ...followUp.map(text => ({ mode: "followUp", text, images: this.#takeImages(text) })),
85
+ ];
86
+ this.#queuedImages = [];
87
+ const [first, ...rest] = queued;
88
+ if (first === undefined)
89
+ return;
90
+ this.#lastPrompt = first.text;
91
+ let accepted = false;
92
+ const started = this.session.prompt(first.text, {
93
+ streamingBehavior: first.mode,
94
+ preflightResult: success => { accepted = success; },
95
+ ...(first.images === undefined ? {} : { images: [...first.images] }),
96
+ });
97
+ for (const item of rest)
98
+ await this.#queue(item.mode, item.text, item.images);
99
+ started.catch(async (error) => {
100
+ // Security: a run that reached the provider is never resent; only a refused start restores.
101
+ try {
102
+ if (!accepted)
103
+ await this.#restoreQueue(queued);
104
+ }
105
+ finally {
106
+ onFailure(error);
107
+ }
108
+ }).catch(() => undefined);
109
+ }
110
+ /** Forgets the attachments kept for queued messages; the queue itself was cleared by the caller. */
111
+ forgetQueuedImages() {
112
+ this.#queuedImages = [];
113
+ }
114
+ // Rationale: the rest were re-queued before the refused start surfaced, so the queue is rebuilt
115
+ // in the original order; anything queued in between keeps its place after it.
116
+ async #restoreQueue(queued) {
117
+ const { steering, followUp } = this.session.clearQueue();
118
+ const restored = new Set(queued.map(item => item.text));
119
+ const extra = [
120
+ ...steering.filter(text => !restored.has(text)).map(text => ({ mode: "steer", text, images: this.#takeImages(text) })),
121
+ ...followUp.filter(text => !restored.has(text)).map(text => ({ mode: "followUp", text, images: this.#takeImages(text) })),
122
+ ];
123
+ this.#queuedImages = [];
124
+ for (const item of [...queued, ...extra])
125
+ await this.#queue(item.mode, item.text, item.images);
126
+ }
127
+ async #queue(mode, text, images) {
128
+ if (images !== undefined && images.length > 0)
129
+ this.#queuedImages.push({ text, images: [...images] });
130
+ if (mode === "steer")
131
+ await this.session.steer(text, images === undefined ? undefined : [...images]);
132
+ else
133
+ await this.session.followUp(text, images === undefined ? undefined : [...images]);
134
+ }
135
+ #takeImages(text) {
136
+ const index = this.#queuedImages.findIndex(item => item.text === text);
137
+ if (index === -1)
138
+ return undefined;
139
+ const [item] = this.#queuedImages.splice(index, 1);
140
+ return item?.images;
141
+ }
142
+ #isExtensionCommand(text) {
143
+ if (!text.startsWith("/"))
144
+ return false;
145
+ const spaceIndex = text.indexOf(" ");
146
+ const name = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
147
+ return this.session.extensionRunner?.getCommand(name) !== undefined;
148
+ }
64
149
  }
65
150
  export function subscribeToPiSessionEvents(session, sessionId, emit, malformed) {
66
151
  let sequence = 0;
@@ -59,6 +59,8 @@ export class OwnedUiSessionShellRoot {
59
59
  #workflowStatusAnchors = new Map();
60
60
  #workflowStatusMessages = new Map();
61
61
  #lastWorkflowStatusId;
62
+ // Invariant: the notice is dock chrome, never transcript content; the custom viewport alone uses it.
63
+ #dockNotice;
62
64
  #inputSurface;
63
65
  #inputSurfaceCoordination = "editor";
64
66
  #dockInputReuseEnabled;
@@ -103,6 +105,7 @@ export class OwnedUiSessionShellRoot {
103
105
  });
104
106
  this.resources = createPiShellLoadedResources(startup.resources ?? [], startup.expanded ?? false);
105
107
  this.#status = createPiShellStatus(view, progressStatusText, handlers);
108
+ this.#status.setProgressPresentation(this.#customViewport ? "custom-viewport" : "pinned");
106
109
  this.#footer = createPiShellFooter(this.#viewWithExtensionStatuses(view), cwd, this.#customViewport ? "a1" : "pi");
107
110
  this.#queued = createPiQueuedInputStatus(view.editor.queuedSubmissions, this.#customViewport ? "custom-viewport" : "pinned");
108
111
  this.editor = createPiShellEditor({
@@ -340,6 +343,8 @@ export class OwnedUiSessionShellRoot {
340
343
  this.#documentLayouts.clear();
341
344
  }
342
345
  #mountTranscript(block) {
346
+ // Invariant: new content dismisses the notice; a revision of a mounted block keeps it.
347
+ this.#dismissDockNotice();
343
348
  const created = createPiShellTranscriptComponent(block, this.#cwd, this.#extensionRenderers, this.#submittedPromptComposer, this.#outputPad, !this.#thinkingVisible, this.#mermaidRenderingMode, this.#showImages, this.#imageWidthCells, this.#imageAssets, { ...this.#componentRuntime, changed: () => {
344
349
  if (this.#transcript.get(block.id) !== created)
345
350
  return;
@@ -521,7 +526,7 @@ export class OwnedUiSessionShellRoot {
521
526
  #renderDockLayout(width) {
522
527
  const queued = this.#customViewport ? [] : this.#renderQueued(width);
523
528
  const statusRows = this.#customViewport ? this.#status.renderDock(width) : this.#renderStatus(width);
524
- const transientRows = [...queued, ...statusRows];
529
+ const transientRows = [...queued, ...statusRows, ...this.#renderDockNotice(width)];
525
530
  const aboveWidgets = this.#renderWidgets("aboveEditor", width);
526
531
  const input = this.#inputSurface.render(width);
527
532
  // Invariant: pointer rows describe the body, not the autocomplete block now preceding it.
@@ -535,6 +540,17 @@ export class OwnedUiSessionShellRoot {
535
540
  inputRows: body?.rowCount ?? input.length,
536
541
  };
537
542
  }
543
+ #renderDockNotice(width) {
544
+ if (this.#dockNotice === undefined)
545
+ return [];
546
+ return ["", ...renderPiShellStatusText(this.#dockNotice, width, this.#outputPad)];
547
+ }
548
+ #dismissDockNotice() {
549
+ if (this.#dockNotice === undefined)
550
+ return;
551
+ this.#dockNotice = undefined;
552
+ this.#invalidateChrome();
553
+ }
538
554
  #renderQueued(width) {
539
555
  return this.#view.editor.queuedSubmissions.length === 0 ? [] : this.#queued.render(width);
540
556
  }
@@ -733,6 +749,14 @@ export class OwnedUiSessionShellRoot {
733
749
  return rows.join("\n");
734
750
  }
735
751
  appendWorkflowStatus(message) {
752
+ // Rationale: bare A1 acknowledges commands in one dock notice above the editor, where the
753
+ // reader's eye already is, instead of a transcript row that opens an empty session at the
754
+ // top-left or sinks into a long feed. The pinned route keeps Pi's chat placement.
755
+ if (this.#customViewport) {
756
+ this.#dockNotice = message;
757
+ this.#invalidateChrome();
758
+ return;
759
+ }
736
760
  const previousId = this.#lastWorkflowStatusId;
737
761
  if (previousId !== undefined
738
762
  && this.#transcriptOrder.at(-1) === previousId
@@ -872,6 +896,7 @@ export class OwnedUiSessionShellRoot {
872
896
  this.invalidate();
873
897
  }
874
898
  resetWorkflowPresentation() {
899
+ this.#dockNotice = undefined;
875
900
  for (const id of this.#workflowStatusAnchors.keys()) {
876
901
  this.#transcript.get(id)?.dispose?.();
877
902
  this.#transcript.delete(id);
@@ -1056,6 +1081,8 @@ export class OwnedUiSessionShellRoot {
1056
1081
  this.#transcriptOrder = order;
1057
1082
  }
1058
1083
  #appendAnchoredWorkflowComponent(render, dispose) {
1084
+ // Invariant: transcript-bound workflow output supersedes a pending acknowledgement.
1085
+ this.#dismissDockNotice();
1059
1086
  this.#workflowTranscriptSequence += 1;
1060
1087
  const id = `workflow-status-${this.#workflowTranscriptSequence}`;
1061
1088
  const component = {
@@ -66,7 +66,6 @@ export class OwnedUiSessionShell {
66
66
  #showImages = true;
67
67
  #imageWidthCells = 80;
68
68
  #fullscreenExitOutput = "transcript";
69
- #compactionQueue = [];
70
69
  #waitingImages = new Map();
71
70
  #lastClearTime = 0;
72
71
  #lastEscapeTime = 0;
@@ -494,8 +493,6 @@ export class OwnedUiSessionShell {
494
493
  model: event.model,
495
494
  });
496
495
  }
497
- if (view.lifecycle === "ready" && this.#compactionQueue.length > 0)
498
- void this.#flushCompactionQueue();
499
496
  if (event.type === "session-lifecycle" && event.lifecycle === "stopped")
500
497
  this.#settleStoppedLifecycle();
501
498
  });
@@ -642,18 +639,8 @@ export class OwnedUiSessionShell {
642
639
  }
643
640
  }
644
641
  }
645
- if (this.view().status.workingMessage?.startsWith("Compacting") === true) {
646
- this.#rememberInput(displayInput, "steer");
647
- this.#compactionQueue.push({
648
- text: input,
649
- draft: displayInput,
650
- type: "steer",
651
- ...(prepared.images.length === 0 ? {} : { images: prepared.images }),
652
- });
653
- this.root.appendWorkflowResult({ command: "compact", outcome: "completed", message: `Queued during compaction: ${input}` });
654
- this.runtime.requestRender();
655
- return { outcome: "completed", diagnostic: null };
656
- }
642
+ // Compatibility: match interactive Pi: input during compaction is queued steering; the engine
643
+ // shows it in the pending rows and delivers it when compaction ends.
657
644
  const type = this.view().lifecycle === "busy" ? "steer" : "prompt";
658
645
  this.#rememberInput(displayInput, type);
659
646
  this.root.resumeViewportFollowing();
@@ -772,15 +759,6 @@ export class OwnedUiSessionShell {
772
759
  if (this.root.editor.getText() === draft)
773
760
  this.root.editor.setText("");
774
761
  this.root.resumeViewportFollowing();
775
- if (this.view().status.workingMessage?.startsWith("Compacting") === true) {
776
- this.#compactionQueue.push({
777
- text,
778
- draft: displayInput,
779
- type: "follow-up",
780
- ...(prepared.images.length === 0 ? {} : { images: prepared.images }),
781
- });
782
- return { outcome: "completed", diagnostic: null };
783
- }
784
762
  return this.#execute({
785
763
  type: "follow-up",
786
764
  correlationId: this.#correlation("follow-up"),
@@ -790,9 +768,8 @@ export class OwnedUiSessionShell {
790
768
  }, displayInput);
791
769
  }
792
770
  restoreQueuedInput() {
793
- const queued = [...this.#waitingImages.keys(), ...this.backend.clearQueuedWorkflows(), ...this.#compactionQueue.map(item => item.draft)];
771
+ const queued = [...this.#waitingImages.keys(), ...this.backend.clearQueuedWorkflows()];
794
772
  this.#cancelWaitingImages();
795
- this.#compactionQueue = [];
796
773
  if (queued.length === 0)
797
774
  return;
798
775
  this.root.editor.setText(queued.join("\n"));
@@ -1773,19 +1750,6 @@ export class OwnedUiSessionShell {
1773
1750
  this.root.setInputSurface(null);
1774
1751
  this.runtime.requestRender();
1775
1752
  }
1776
- async #flushCompactionQueue() {
1777
- const queued = this.#compactionQueue;
1778
- this.#compactionQueue = [];
1779
- for (const item of queued) {
1780
- await this.#execute({
1781
- type: item.type,
1782
- correlationId: this.#correlation(`compaction-${item.type}`),
1783
- sessionId: this.backend.sessionId,
1784
- text: item.text,
1785
- ...(item.images === undefined ? {} : { images: item.images }),
1786
- }, item.draft);
1787
- }
1788
- }
1789
1753
  async #execute(command, draft) {
1790
1754
  if (draft === undefined)
1791
1755
  return this.backend.execute(command);
@@ -5,7 +5,7 @@
5
5
  "platform": "darwin",
6
6
  "architecture": "arm64",
7
7
  "capability": "supported",
8
- "builtAt": "2026-09-17T13:26:08.181Z",
8
+ "builtAt": "2026-09-17T18:17:47.242Z",
9
9
  "artifact": {
10
10
  "filename": "process-guardian",
11
11
  "sha256": "9db1726bbe3fc2e8292f2ead1e7e9d0fd4d7d0dc9217b372582bf354b0f565dc",
@@ -5,7 +5,7 @@
5
5
  "platform": "linux",
6
6
  "architecture": "x64",
7
7
  "capability": "supported",
8
- "builtAt": "2026-09-17T13:26:00.541Z",
8
+ "builtAt": "2026-09-17T18:17:37.589Z",
9
9
  "artifact": {
10
10
  "filename": "process-guardian",
11
11
  "sha256": "d8cda6b0c7cb36c0cc41e90802aceebf6c02eaebbc08a83d7d963d2e918dbd7a",
@@ -5,10 +5,10 @@
5
5
  "platform": "win32",
6
6
  "architecture": "x64",
7
7
  "capability": "supported",
8
- "builtAt": "2026-09-17T13:27:04.398Z",
8
+ "builtAt": "2026-09-17T18:18:40.058Z",
9
9
  "artifact": {
10
10
  "filename": "process-guardian.exe",
11
- "sha256": "f7e5072d6fddb8e0672d8274699e8e47f515d98776ba3bd79b00391fcd38e667",
11
+ "sha256": "bb9636bcbdcbd48220ad6beb4934afc7b07c369e0abe1906d9dacc45a67ec675",
12
12
  "size": 177664
13
13
  },
14
14
  "provenance": {
@@ -22,6 +22,20 @@ node scripts/governance/local-worktree-cleanup.mjs sweep --repo D:/Git/a1
22
22
 
23
23
  `sweep` evaluates every released registration in one pass under the queue limits (100 registrations, 500 remote requests, a durable round-robin cursor) with a 180-second elapsed budget, since each merged candidate costs three evidence loads, and completes each candidate whose PR is verified merged using exactly the `complete` safeguards. It needs no `enable` and starts no process: its authority is each candidate's release. A candidate whose PR is open, draft, or not yet finalized reports `pending`; a candidate whose PR closed without merge reports `awaiting-discard` and is never touched; blockers report their exact reason. An old stop sentinel does not prevent a sweep, but `disable` run while a sweep is executing stops it before its next destructive step. If another session holds the mutation lock the sweep reports `mutation-busy` and the agent relays it as deferred rather than waiting. The JSON report carries a `lines` array with one relayable line per candidate and pruned branch, for example `#458 close-absent-and-skewed-cleanup: removed [worktree-removed, local-ref-removed]`. Pending or blocked results never delay the new delivery.
24
24
 
25
+ ### Nothing left to remove
26
+
27
+ A released entry whose evidence can never verify (for example `delivery-content-drift` after a stale-base merge, or `source-association` for a PR without an `openspec-implementation` fence) would otherwise be re-verified and reported `blocked` on every sweep. When such an entry's worktree path is gone, Git holds no live row for it (its own dangling row is retired), and its local topic ref is absent, the sweep and `complete` ask GitHub only whether the pull request merged into `develop` in this repository and then mark the journal `done` with `retired-nothing-left` and the original evidence reason. Nothing is deleted; a present path, row, or ref keeps the entry blocked, an unmerged PR keeps it `pending` or `awaiting-discard`, a deferred failure such as `remote-budget` is never treated as unverifiable, and preview retires nothing. For an all-absent entry whose PR is not merged, the maintainer runs the explicit form, which reads nothing from GitHub and deletes nothing:
28
+
29
+ ```bash
30
+ node scripts/governance/local-worktree-cleanup.mjs forget --repo D:/Git/a1 --id REGISTRATION_ID --confirm-nothing-left
31
+ ```
32
+
33
+ `forget` refuses without the flag, for an owned or deleting entry, and while the path, a live Git row, or the ref still exists (`something-remains`). `status` shows both notes under `completion`.
34
+
35
+ ### Keep the base current before merge
36
+
37
+ The `develop` ruleset requires the pull request head to be up to date with `develop`. When another delivery merges first, the PR shows `BEHIND`: merge `origin/develop` into the branch (or use "Update branch"), push, let the `OpenSpec finalization` workflow re-finalize with digests of the merged bytes, wait for validation, and hand off again. Merging a stale finalized head is what produced `delivery-content-drift` for #461; with the strict policy GitHub refuses that merge.
38
+
25
39
  ### Accepted ancestry
26
40
 
27
41
  Since the `OpenSpec finalization` workflow pushes its commit onto the PR branch after the agent's last push, the registered head is normally one commit behind the merged head. Cleanup therefore accepts a registered head, live worktree HEAD, or local topic-ref tip that equals the merged PR head or is one of its ancestors on GitHub, verified with the compare API: every commit reachable from such a tip is reachable from a head the maintainer accepted, so nothing is lost. A tip that holds a commit outside the merged head, or a commit GitHub does not know, still blocks with `candidate-head-association`, `worktree-identity-changed`, or `local-ref-advanced`, and the branch attachment must still be the registered one. Ref deletion reads the tip immediately before deleting, requires it to be accepted, and uses it as the compare-and-delete expectation. Ancestry of `develop` is never used, because the repository squash-merges.
@@ -162,6 +176,8 @@ State, journals, stop controls, and execution reports live in `<git-common-dir>/
162
176
  - `unmanaged`: no local registration; no automatic adoption.
163
177
  - `removed`: worktree and eligible local-ref operations were verified.
164
178
  - `already-absent`: a completed journal's path/ref are still absent.
179
+ - `retired`: evidence was unverifiable but nothing remained to delete and the PR is merged; the journal is complete with `retired-nothing-left`.
180
+ - `forgotten`: the maintainer explicitly closed an all-absent entry with `forget`.
165
181
  - `partial`: a destructive step began but all cleanup could not be verified; the same command resumes it.
166
182
  - `deferred`: a bounded pass or concurrent mutation owner prevented evaluation.
167
183
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timurproko/a1",
3
- "version": "0.1.8-dev.459",
3
+ "version": "0.1.8-dev.467",
4
4
  "description": "Standalone terminal workspace for supervised native and managed agents",
5
5
  "type": "module",
6
6
  "privateLaunchContract": "neutral-launch-v1",