@visual-z/xeno 0.8.2 → 0.8.3
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/CHANGELOG.md +42 -0
- package/package.json +1 -1
- package/src/apps/shell.ts +13 -3
- package/src/apps/startup.ts +4 -4
- package/src/apps/ui/app.ts +32 -14
- package/src/apps/ui/project.ts +1 -1
- package/src/apps/ui/standalone.ts +4 -2
- package/src/apps/ui/surface.ts +2 -0
- package/src/core/activity-status.ts +17 -2
- package/src/core/command-contract.ts +58 -16
- package/src/core/completion-review.ts +1 -1
- package/src/core/derive.ts +9 -1
- package/src/core/event-validation.ts +19 -3
- package/src/core/events.ts +38 -2
- package/src/core/evidence.ts +149 -7
- package/src/core/export.ts +2 -0
- package/src/core/external-effects.ts +488 -0
- package/src/core/handoff.ts +24 -3
- package/src/core/history-query.ts +1 -1
- package/src/core/history.ts +124 -20
- package/src/core/observation-view.ts +24 -0
- package/src/core/path-suggestions.ts +59 -0
- package/src/core/permissions.ts +60 -0
- package/src/core/prompt-policy.ts +4 -2
- package/src/core/secrets.ts +188 -0
- package/src/core/stop-report.ts +25 -3
- package/src/core/tool-failure.ts +11 -3
- package/src/core/transcript.ts +3 -0
- package/src/core/work-state.ts +7 -2
- package/src/core/working-set.ts +34 -6
- package/src/runtime/connection-reuse.ts +40 -0
- package/src/runtime/exec-sessions.ts +66 -18
- package/src/runtime/history-tools.ts +12 -2
- package/src/runtime/loop.ts +98 -14
- package/src/runtime/open-image.ts +42 -0
- package/src/runtime/openai-responses.ts +15 -7
- package/src/runtime/openai.ts +15 -7
- package/src/runtime/permission-resources.ts +11 -4
- package/src/runtime/pi-provider.ts +34 -9
- package/src/runtime/provider.ts +19 -8
- package/src/runtime/retry.ts +152 -9
- package/src/runtime/secrets.ts +79 -0
- package/src/runtime/session-control.ts +9 -1
- package/src/runtime/stream-idle.ts +80 -0
- package/src/runtime/tools.ts +97 -27
- package/src/runtime/turn-effects.ts +31 -0
- package/src/store/context-files.ts +5 -1
- package/src/store/file-checkpoints.ts +1 -1
- package/src/store/log.ts +20 -0
- package/src/store/replay.ts +4 -3
- package/src/store/secrets.ts +44 -0
- package/src/store/sessions.ts +49 -6
- package/src/tui/ui/image-size.ts +6 -2
- package/src/tui/ui/items.ts +3 -1
- package/src/tui/ui/pictures.ts +14 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,47 @@
|
|
|
1
1
|
# xeno changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.3 — 2026-10-12
|
|
4
|
+
|
|
5
|
+
Credentials stay out of the log and out of the model's context:
|
|
6
|
+
- A key, token or password in your message or in a tool's output is replaced by a placeholder such as `[[xeno-secret-1]]` before anything is written. Only high-confidence shapes are recognised (provider key prefixes such as `sk-`, `ghp_`, `tskey-`, `AKIA`; JWTs; private keys; `password=` / `token:` / `密码是` followed by a value); there is no entropy guessing.
|
|
7
|
+
- A short reply sent right after the assistant asked for a password or token is treated as that credential.
|
|
8
|
+
- The model can still use it: a placeholder in a command or file is turned back into the real value when the tool runs, and hidden again if the output repeats it. Values live in the session's `secrets.jsonl` (owner-only), never in an event, a handoff or an export.
|
|
9
|
+
- Credentials already in older logs are not rewritten; rotate any you pasted before this release.
|
|
10
|
+
|
|
11
|
+
Knowing what happened:
|
|
12
|
+
- A failed request records why: `HTTP 503 after 3 retries`, `connection lost`, `stream stalled`. Reopening the session still shows it. The provider's error text is still never written. An error inside xeno itself is recorded as an execution error, not a provider error.
|
|
13
|
+
- Each approval is logged: who allowed the call (you, an earlier choice, a configured rule, `--yolo`) and which rule matched.
|
|
14
|
+
- When a request ends, xeno lists what it did outside the workspace: writes, `kill`, system settings, `brew`/`npm -g`, non-GET `curl`, `git push`. Nothing is blocked; the list is there so a blanket approval is not silent.
|
|
15
|
+
- Session logs record the xeno version that wrote them.
|
|
16
|
+
|
|
17
|
+
Network:
|
|
18
|
+
- Dropped connections are retried up to ten times, backing off to 32s, and the retry line says `retry 3/10 · connection lost`. A socket Bun closed or a host it could not reach is now retried; certificate failures are not.
|
|
19
|
+
- After a connection dies, later requests open fresh connections instead of reusing a pooled dead socket.
|
|
20
|
+
- A stream that sends nothing for 300s is abandoned and sent again, at most twice, instead of waiting for Escape.
|
|
21
|
+
|
|
22
|
+
Verification and continuation:
|
|
23
|
+
- A check run as `build && test`, with its output redirected to a file, or as `make build test lint` (also `just` and `task`) counts as verification. The model is no longer asked to run it again on its own.
|
|
24
|
+
- A stop report lists a claim that was recorded but not verified as unverified, not as a failed tool, and no longer reads an uncapped request count as a limit that was hit.
|
|
25
|
+
- Switching models before anything has happened no longer starts a new segment, so the model does not open from an empty handoff and go looking for work.
|
|
26
|
+
- A next step written in an earlier segment no longer counts as a current plan; the boundary reminder asks the model to confirm or update it.
|
|
27
|
+
- New segments carry every one of your messages before other history, name the screenshots attached to them (`history(event=…)` brings an image back), and keep one copy of each next step.
|
|
28
|
+
|
|
29
|
+
Tools:
|
|
30
|
+
- `write_stdin` with Ctrl-C or Ctrl-D to a process that already exited returns its exit and unread output instead of an error.
|
|
31
|
+
- An empty `write_stdin` wait collects output until its window ends instead of waking for every line, so a chatty process no longer costs one request per line.
|
|
32
|
+
- When `read` or `edit` cannot find a path, the error names up to three closest existing paths.
|
|
33
|
+
- Paths outside the workspace skip instruction discovery, so the answer is the path rule's, not "instruction target escapes workspace".
|
|
34
|
+
- Refusing to write under `.git` or `.xeno` says it is not a permission problem and no approval can allow it.
|
|
35
|
+
- Exit 141 from `… | head` is read as the reader closing early, not a failure. Progress bars drawn with carriage returns reach the model as the line a terminal shows.
|
|
36
|
+
|
|
37
|
+
Interface:
|
|
38
|
+
- Pictures in the transcript may take half the screen (up to twenty rows); clicking one opens the original image.
|
|
39
|
+
- `-c`, the resume pickers and `/sessions` skip sessions where you never sent a message.
|
|
40
|
+
- A forked session shows `forked · HH:MM` and is timed from the fork, not from its parent's first message.
|
|
41
|
+
|
|
42
|
+
Fixes:
|
|
43
|
+
- Reasoning a gateway sent without a signature is replayed as reasoning, so Gemini through such a gateway stops copying its thinking into replies.
|
|
44
|
+
|
|
3
45
|
## 0.8.2 — 2026-10-11
|
|
4
46
|
|
|
5
47
|
The stall watchdog no longer stops work that is merely long:
|
package/package.json
CHANGED
package/src/apps/shell.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { criterionSummary, draftGoal, pickCriteria, type PendingGoal } from "./goal-flow.ts";
|
|
10
|
+
import { openImageFile } from "../runtime/open-image.ts";
|
|
10
11
|
import { closeAppSession, switchAppSession } from "./session-lifecycle.ts";
|
|
11
12
|
import { basename, dirname, join, resolve } from "node:path";
|
|
12
13
|
import { tmpdir } from "node:os";
|
|
@@ -58,7 +59,7 @@ import { reloadAppResources } from "./resources.ts";
|
|
|
58
59
|
import { publishModelCatalog } from "./model-catalog.ts";
|
|
59
60
|
import { readCursor, writeCursor, listNodes, readNode, readManifest, setSessionLabel } from "../store/log.ts";
|
|
60
61
|
import { registerSessionRoot } from "../store/session-browser.ts";
|
|
61
|
-
import { createSession, findSessionByPrefix, listSessions, truncationNote, sessionsDir } from "../store/sessions.ts";
|
|
62
|
+
import { createSession, findSessionByPrefix, hasConversation, listSessions, truncationNote, sessionsDir } from "../store/sessions.ts";
|
|
62
63
|
import { exportSessionArchive, importSessionArchive } from "../store/session-archive.ts";
|
|
63
64
|
import { resolveTuiMode } from "../core/tui-mode.ts";
|
|
64
65
|
import { modelPreferencePatch, thinkingPreferencePatch } from "../core/model-preferences.ts";
|
|
@@ -75,6 +76,8 @@ import { createStyle, IDENTITY } from "../tui/style.ts";
|
|
|
75
76
|
import { lastAssistantText, toHtml } from "../core/export.ts";
|
|
76
77
|
import { toJsonEvent } from "../core/json-event.ts";
|
|
77
78
|
import { sessionControl } from "../runtime/session-control.ts";
|
|
79
|
+
import { hostExternalOptions, requestExternalEffects } from "../runtime/turn-effects.ts";
|
|
80
|
+
import { formatExternalEffects } from "../core/external-effects.ts";
|
|
78
81
|
import { COMMANDS, parseCommand, type Command } from "./commands.ts";
|
|
79
82
|
import { expandInput } from "./input.ts";
|
|
80
83
|
import {
|
|
@@ -433,6 +436,8 @@ export const interactiveHooks = {
|
|
|
433
436
|
effortSupported: (spec: ModelSpec, level: Effort) => effortSupported(spec, level),
|
|
434
437
|
fileIndexFor: (app: App, refresh?: boolean) => fileIndexFor(app, refresh),
|
|
435
438
|
dropFileIndex: (workspace: string) => { fileIndexes.delete(workspace); },
|
|
439
|
+
/** Show a stored image in the system viewer; resolves to why it could not, or null. */
|
|
440
|
+
openImage: (app: App, sha256: string) => openImageFile(app.blobDir, sha256),
|
|
436
441
|
};
|
|
437
442
|
|
|
438
443
|
type Outcome = "continue" | "quit" | { login: string };
|
|
@@ -1089,6 +1094,8 @@ async function printSessionList(app: App): Promise<void> {
|
|
|
1089
1094
|
app.io.say(C.red(` ${s.id} unreadable ${s.error}`));
|
|
1090
1095
|
continue;
|
|
1091
1096
|
}
|
|
1097
|
+
// A session left without a word is noise here; the current one stays (fix-skip-empty-sessions).
|
|
1098
|
+
if (s.path !== app.logDir && !await hasConversation(s.path)) continue;
|
|
1092
1099
|
const here = s.path === app.logDir ? C.cyan(" now ") : " ";
|
|
1093
1100
|
const label = (s.name ?? s.id).padEnd(24);
|
|
1094
1101
|
app.io.say(` ${here} ${label}${C.dim(`${s.events} events ${s.model ?? ""}`)}`);
|
|
@@ -1652,9 +1659,8 @@ export async function switchToModel(app: App, spec: ModelSpec, thinking?: Effort
|
|
|
1652
1659
|
thinkingDisplay: app.providerConfig.thinkingDisplay,
|
|
1653
1660
|
};
|
|
1654
1661
|
const next = makeProvider(app, nextConfig, got.key);
|
|
1655
|
-
app.io.say(C.dim(" new segment: the next model starts from a handoff, not this model's thinking"));
|
|
1656
1662
|
try {
|
|
1657
|
-
await app.session.switchModel(next);
|
|
1663
|
+
if (await app.session.switchModel(next)) app.io.say(C.dim(" new segment: the next model starts from a handoff, not this model's thinking"));
|
|
1658
1664
|
} catch (err) {
|
|
1659
1665
|
app.io.say(C.red(` cannot switch: ${describeError(err)}`));
|
|
1660
1666
|
return;
|
|
@@ -2041,6 +2047,10 @@ export async function runTurn(
|
|
|
2041
2047
|
app.io.say(line);
|
|
2042
2048
|
}
|
|
2043
2049
|
} } finally { await registry?.emit("agent_end", { messages: structuredClone(app.session.state.events) }); }
|
|
2050
|
+
// feat-external-effects-summary: one line, in every mode (print and json send it to stderr).
|
|
2051
|
+
const host = hostExternalOptions();
|
|
2052
|
+
const outside = formatExternalEffects(skill ? requestExternalEffects(app.session, host) : sessionControl(app.session).lastExternalEffects, host);
|
|
2053
|
+
if (outside) { if (tui?.notice) tui.notice(outside, "warning"); else app.io.say(app.io.palette.yellow(` ${outside}`)); }
|
|
2044
2054
|
if (failure) throw Error(failure);
|
|
2045
2055
|
if (completed) {
|
|
2046
2056
|
const text = finalText.join("\n");
|
package/src/apps/startup.ts
CHANGED
|
@@ -48,7 +48,7 @@ import { refreshRemoteCatalog } from "../runtime/models-fetch.ts";
|
|
|
48
48
|
import { readCursor, writeCursor, type Cursor } from "../store/log.ts";
|
|
49
49
|
import { getBlob } from "../store/blobs.ts";
|
|
50
50
|
import { parseSessionContext } from "../core/session-format.ts";
|
|
51
|
-
import { resolveLogDir, findSessionByPrefix,
|
|
51
|
+
import { resolveLogDir, findSessionByPrefix, newestConversation, sessionsDir, type ResolveLogOpts } from "../store/sessions.ts";
|
|
52
52
|
import { recoverCut } from "../store/recovery.ts";
|
|
53
53
|
import { planSessionEntry, restoreSessionModel } from "../core/session-entry.ts";
|
|
54
54
|
import { loadContext, assembleSystemPrompt, formatNotice, type LoadedContext } from "../store/context-files.ts";
|
|
@@ -1270,9 +1270,9 @@ export async function openSessionFor(opts: OpenSessionOptions): Promise<{
|
|
|
1270
1270
|
session.config.tools.workflowMode = opts.args.workflow ?? "build";
|
|
1271
1271
|
session.config.tools.deferredTools = restoredTools.deferred;
|
|
1272
1272
|
if (restoring.id !== spec.id || restoring.provider !== spec.provider) {
|
|
1273
|
-
await session.switchModel(createProvider(providerConfig, credential.ok ? credential.key : "", retryHandler(opts.io),
|
|
1273
|
+
const cut = await session.switchModel(createProvider(providerConfig, credential.ok ? credential.key : "", retryHandler(opts.io),
|
|
1274
1274
|
{ sessionId: providerSessionId(session.config.logDir), ...(oauth(spec, credential) ? { oauth: true } : {}), ...(opts.effects ? { effects: opts.effects } : {}) }));
|
|
1275
|
-
opts.io.say(opts.io.palette.yellow(` switched ${restoring.id} → ${spec.id} at a model checkpoint`));
|
|
1275
|
+
opts.io.say(opts.io.palette.yellow(` switched ${restoring.id} → ${spec.id}${cut ? " at a model checkpoint" : ""}`));
|
|
1276
1276
|
}
|
|
1277
1277
|
resources.ui.settings = { ...resources.ui.settings, thinking: providerConfig.effort,
|
|
1278
1278
|
...(opts.args.modelsExplicit || opts.args.models ? { scopedModels: splitModelPatterns(opts.args.models) } : {}) };
|
|
@@ -1415,7 +1415,7 @@ export async function startup(args: Args, input?: StartupInput): Promise<App> {
|
|
|
1415
1415
|
let resourceLog: string | undefined;
|
|
1416
1416
|
if (target) { const found = await findSessionByPrefix(workspace, target, sessionDirFor(args, workspace)); if (found.ok) resourceLog = found.session.path; }
|
|
1417
1417
|
else if (args.sessionId) resourceLog = join(sessionsDir(workspace, sessionDirFor(args, workspace)), args.sessionId);
|
|
1418
|
-
else if (args.continueLatest) resourceLog = (await
|
|
1418
|
+
else if (args.continueLatest) resourceLog = (await newestConversation(workspace, sessionDirFor(args, workspace)))?.path;
|
|
1419
1419
|
if (resourceLog) resourceWorkspace = (await readCursor(resourceLog))?.workspace ?? workspace;
|
|
1420
1420
|
}
|
|
1421
1421
|
input?.phase("Loading session resources");
|
package/src/apps/ui/app.ts
CHANGED
|
@@ -37,6 +37,8 @@ import { DEFAULT_FULLSCREEN_EXIT_OUTPUT, resumeCommand, sessionExitStatus } from
|
|
|
37
37
|
import { lastAssistantText } from "../../core/export.ts";
|
|
38
38
|
import type { PermissionRequest } from "../../core/permissions.ts";
|
|
39
39
|
import type { ApprovalChoice } from "../../runtime/tools.ts";
|
|
40
|
+
import { formatExternalEffects, type ExternalEffect } from "../../core/external-effects.ts";
|
|
41
|
+
import { hostExternalOptions } from "../../runtime/turn-effects.ts";
|
|
40
42
|
import { attachImageBytes, attachImageFile } from "../../runtime/images.ts";
|
|
41
43
|
import { readClipboard, writeClipboard, type ClipboardContent } from "../../runtime/clipboard.ts";
|
|
42
44
|
import { highlightCode, transformMermaid, warmSyntaxLanguages } from "../../runtime/rich-text.ts";
|
|
@@ -49,12 +51,12 @@ import { updateNotice } from "../../core/update-check.ts";
|
|
|
49
51
|
import { assertNever } from "../../core/exhaustive.ts";
|
|
50
52
|
import { isUpgrade } from "../../core/version.ts";
|
|
51
53
|
import type { Effort } from "../../runtime/model.ts";
|
|
52
|
-
import { readNode, readCursor, setSessionLabel } from "../../store/log.ts";
|
|
54
|
+
import { readNode, readCursor, setSessionLabel, forkedAt } from "../../store/log.ts";
|
|
53
55
|
import { readNodeHistory } from "../../store/restore.ts";
|
|
54
56
|
import { sessionsDir } from "../../store/sessions.ts";
|
|
55
57
|
import { readGitStatus } from "../../store/git-status.ts";
|
|
56
58
|
import type { GitStatus } from "../../core/git-status.ts";
|
|
57
|
-
import { activityStatus } from "../../core/activity-status.ts";
|
|
59
|
+
import { activityStatus, retryLine, type RetryFacts } from "../../core/activity-status.ts";
|
|
58
60
|
import { goalStatus } from "../../core/goal-view.ts";
|
|
59
61
|
import { generationSpeed } from "../../runtime/usage.ts";
|
|
60
62
|
import { executionOutcome, type ExecutionOutcome } from "../../core/outcome.ts";
|
|
@@ -155,7 +157,7 @@ export class InteractiveApp implements Ui {
|
|
|
155
157
|
private historyLength = -1;
|
|
156
158
|
private notices: Notice[] = [];
|
|
157
159
|
private noticeSerial = 0;
|
|
158
|
-
private live: { thinking: string; text: string; tools: Map<string, { name: string; arguments: string }>; output: string; bash: string | null; retryUntil: number;
|
|
160
|
+
private live: { thinking: string; text: string; tools: Map<string, { name: string; arguments: string }>; output: string; bash: string | null; retryUntil: number; retry: RetryFacts | null } = emptyLive();
|
|
159
161
|
private retryTimer: ReturnType<typeof setInterval> | undefined;
|
|
160
162
|
private bashDecoder = new TextDecoder();
|
|
161
163
|
private branch: string | null;
|
|
@@ -194,6 +196,8 @@ export class InteractiveApp implements Ui {
|
|
|
194
196
|
private files: ReturnType<typeof fileChanges> = [];
|
|
195
197
|
private prompt: string | null = null;
|
|
196
198
|
private startedAt: string | undefined;
|
|
199
|
+
/** Set for a forked session: its clock starts at the fork, not at the copied parent history (fix-fork-start-time). */
|
|
200
|
+
private forkedAt: string | null = null;
|
|
197
201
|
private budgetKey = "";
|
|
198
202
|
private budgetCache: ContextBudget | undefined;
|
|
199
203
|
/** An extension's own terminal title wins over ours; ours is written only when it changes. */
|
|
@@ -464,6 +468,7 @@ export class InteractiveApp implements Ui {
|
|
|
464
468
|
this.clearStream();
|
|
465
469
|
void this.refreshGit();
|
|
466
470
|
this.sessionName = (await readCursor(dir))?.name ?? this.sessionName;
|
|
471
|
+
this.forkedAt = await forkedAt(dir).catch(() => null);
|
|
467
472
|
this.thinkingExpanded = this.app.ui?.settings.hideThinkingBlock === false;
|
|
468
473
|
void this.refreshFileIndex();
|
|
469
474
|
}
|
|
@@ -481,7 +486,7 @@ export class InteractiveApp implements Ui {
|
|
|
481
486
|
this.historyLength = physical.events.length;
|
|
482
487
|
this.files = fileChanges(this.transcriptItems);
|
|
483
488
|
this.prompt = firstPrompt(this.transcriptItems);
|
|
484
|
-
this.startedAt = events.find(event => typeof event.at === "string")?.at ?? physical.header?.created_at;
|
|
489
|
+
this.startedAt = this.forkedAt ?? events.find(event => typeof event.at === "string")?.at ?? physical.header?.created_at;
|
|
485
490
|
this.requestDerived();
|
|
486
491
|
}
|
|
487
492
|
this.paint();
|
|
@@ -498,7 +503,7 @@ export class InteractiveApp implements Ui {
|
|
|
498
503
|
if (progress.kind === "retry") {
|
|
499
504
|
this.stopRetry();
|
|
500
505
|
this.live.retryUntil = Date.now() + progress.delayMs;
|
|
501
|
-
this.live.
|
|
506
|
+
this.live.retry = { attempt: progress.attempt, seconds: 0, ...(progress.max === undefined ? {} : { max: progress.max }), ...(progress.reason === undefined ? {} : { reason: progress.reason }) };
|
|
502
507
|
this.retryTimer = setInterval(() => this.paint(), 200);
|
|
503
508
|
} else {
|
|
504
509
|
this.stopRetry();
|
|
@@ -563,7 +568,7 @@ export class InteractiveApp implements Ui {
|
|
|
563
568
|
});
|
|
564
569
|
const home = !this.transcriptItems.some(item => item.kind === "user" || item.kind === "assistant" || item.kind === "tool" || item.kind === "shell" || item.kind === "custom") && !this.inflight;
|
|
565
570
|
const clock = this.startedAt ? sessionClock(this.startedAt, new Date()) : undefined;
|
|
566
|
-
const view = projectWithStart(items, clock);
|
|
571
|
+
const view = projectWithStart(items, clock, this.forkedAt !== null);
|
|
567
572
|
this.screen.setGreeting(home ? {
|
|
568
573
|
version: XENO_VERSION, model: app.credential.ok ? spec.name : null, thinking: app.providerConfig.effort,
|
|
569
574
|
workspace: displayPath(app.workspace, homedir()), branch: this.branch,
|
|
@@ -594,7 +599,7 @@ export class InteractiveApp implements Ui {
|
|
|
594
599
|
...(pending ? { tool: pending.name } : this.executionKind === "bash" ? { tool: "bash" } : {}),
|
|
595
600
|
...(this.lastOutcome ? { outcome: this.lastOutcome } : this.controlSnapshot?.goal?.state.execution ? { outcome: this.controlSnapshot.goal.state.execution } : {}),
|
|
596
601
|
...(this.controlSnapshot?.goal ? { goal: this.controlSnapshot.goal.state.status } : {}),
|
|
597
|
-
...(this.live.retryUntil ? { retry: {
|
|
602
|
+
...(this.live.retryUntil && this.live.retry ? { retry: { ...this.live.retry, seconds: (this.live.retryUntil - Date.now()) / 1000 } } : {}) });
|
|
598
603
|
return {
|
|
599
604
|
version: XENO_VERSION, title: this.sessionName ?? this.prompt, workspace: displayPath(app.workspace, homedir()),
|
|
600
605
|
workspaceShort: shortPath(displayPath(app.workspace, homedir())), branch: this.branch,
|
|
@@ -681,7 +686,7 @@ export class InteractiveApp implements Ui {
|
|
|
681
686
|
const l = this.live;
|
|
682
687
|
if (!l.thinking && !l.text && !l.tools.size && !l.output && l.bash === null && !l.retryUntil) return undefined;
|
|
683
688
|
return { thinking: l.thinking, text: l.text, tools: [...l.tools].map(([id, tool]) => ({ id, ...tool })), output: l.output, bash: l.bash,
|
|
684
|
-
...(l.retryUntil ? { retry:
|
|
689
|
+
...(l.retryUntil && l.retry ? { retry: retryLine({ ...l.retry, seconds: (l.retryUntil - Date.now()) / 1000 }) } : {}) };
|
|
685
690
|
}
|
|
686
691
|
|
|
687
692
|
/** The context budget changes only with the log or the model; it is not recounted on every paint. */
|
|
@@ -1139,7 +1144,13 @@ export class InteractiveApp implements Ui {
|
|
|
1139
1144
|
const cell = resolution && this.renderer.terminalWidth > 0 && this.renderer.terminalHeight > 0
|
|
1140
1145
|
? { width: resolution.width / this.renderer.terminalWidth, height: resolution.height / this.renderer.terminalHeight } : DEFAULT_CELL;
|
|
1141
1146
|
return { mode: show ? "picture" : "chip", protocol: effective, cell, widthCells: terminal?.imageWidthCells ?? 60,
|
|
1142
|
-
...(!show ? { reason } : {}), load: sha => this.images.loadResult(sha) };
|
|
1147
|
+
...(!show ? { reason } : {}), load: sha => this.images.loadResult(sha), open: sha => this.openImage(sha) };
|
|
1148
|
+
}
|
|
1149
|
+
/** A click on a picture (feat-tui-image-full-size): the system viewer shows what the thumbnail cannot. */
|
|
1150
|
+
private openImage(sha256: string): void {
|
|
1151
|
+
void this.hooks.openImage(this.app, sha256)
|
|
1152
|
+
.then(reason => { if (reason) this.notice(reason, "warning"); })
|
|
1153
|
+
.catch(error => this.notice(describeError(error), "warning"));
|
|
1143
1154
|
}
|
|
1144
1155
|
private applyPictures(): void {
|
|
1145
1156
|
if (this.disposed) return;
|
|
@@ -1211,6 +1222,9 @@ export class InteractiveApp implements Ui {
|
|
|
1211
1222
|
if (notice.method === "turn/started") { this.inflight = true; this.executionKind = "model"; }
|
|
1212
1223
|
if (notice.method === "turn/completed") {
|
|
1213
1224
|
this.inflight = false; this.executionKind = "idle"; this.clearStream();
|
|
1225
|
+
// feat-external-effects-summary: a remote-driven turn shows the same line a local one does.
|
|
1226
|
+
const outside = formatExternalEffects((notice.params["externalEffects"] ?? []) as ExternalEffect[], hostExternalOptions());
|
|
1227
|
+
if (outside) this.notice(outside, "warning");
|
|
1214
1228
|
const outcome = notice.params["outcome"] as import("../../core/outcome.ts").ExecutionOutcome | undefined;
|
|
1215
1229
|
this.turn = this.continueRemoteTurn(control, outcome).catch(error => this.notice(describeError(error), "error"));
|
|
1216
1230
|
}
|
|
@@ -1482,14 +1496,16 @@ export class InteractiveApp implements Ui {
|
|
|
1482
1496
|
const scan = new AbortController();
|
|
1483
1497
|
const items = (query = ""): ListItem[] => selectSessions(rows, query, sort, false, sessionFuzzy).map(row => ({
|
|
1484
1498
|
value: row.path, label: " ".repeat(Math.min(8, row.depth)) + (row.depth ? "↳ " : "") + (row.name?.trim() || row.text?.split("\n").find(Boolean)?.slice(0, 90) || row.id),
|
|
1485
|
-
description: [row.updated_at?.slice(0, 16).replace("T", " ") ?? "", row.
|
|
1499
|
+
description: [row.updated_at?.slice(0, 16).replace("T", " ") ?? "", row.forked_at ? `forked ${row.forked_at.slice(0, 16).replace("T", " ")}` : "",
|
|
1500
|
+
row.model ?? "", scope === "all" ? row.workspace ?? "" : ""].filter(Boolean).join(" · "),
|
|
1486
1501
|
...(row.path === this.app.logDir ? { hint: "● current" } : {}),
|
|
1487
1502
|
}));
|
|
1488
1503
|
const load = async () => {
|
|
1489
1504
|
rows = [];
|
|
1490
1505
|
const roots = scope === "current" ? [root] : await sessionRoots(root);
|
|
1491
1506
|
for await (const batch of scanSessionRoots(roots, scan.signal)) {
|
|
1492
|
-
|
|
1507
|
+
// Empty sessions are hidden, except the one in use (fix-skip-empty-sessions).
|
|
1508
|
+
rows.push(...batch.sessions.filter((row): row is Row => row.ok && (row.conversation !== false || row.path === this.app.logDir)));
|
|
1493
1509
|
if (this.screen.dialogs.top === spec) this.screen.dialogs.list?.setItems(items(this.screen.dialogs.list.query));
|
|
1494
1510
|
}
|
|
1495
1511
|
};
|
|
@@ -1600,7 +1616,7 @@ export function trustItems(workspace: string): ListItem[] {
|
|
|
1600
1616
|
];
|
|
1601
1617
|
}
|
|
1602
1618
|
|
|
1603
|
-
function emptyLive() { return { thinking: "", text: "", tools: new Map<string, { name: string; arguments: string }>(), output: "", bash: null as string | null, retryUntil: 0,
|
|
1619
|
+
function emptyLive() { return { thinking: "", text: "", tools: new Map<string, { name: string; arguments: string }>(), output: "", bash: null as string | null, retryUntil: 0, retry: null as RetryFacts | null }; }
|
|
1604
1620
|
|
|
1605
1621
|
function noticeOf(id: string, after: number, lines: string[], tone: Tone): Notice & { lines: string[]; tone: Tone } {
|
|
1606
1622
|
const prefix = tone === "error" ? "\x1b[31m" : tone === "warning" ? "\x1b[33m" : tone === "success" ? "\x1b[32m" : "";
|
|
@@ -1672,8 +1688,10 @@ async function printExit(app: App): Promise<void> {
|
|
|
1672
1688
|
}
|
|
1673
1689
|
|
|
1674
1690
|
/** The session-start rule first, then the projection (refactor-tui-dock-layout DoD 7). */
|
|
1675
|
-
function projectWithStart(items: ReturnType<typeof projectItems>, clock: string | undefined): ReturnType<typeof projectItems> {
|
|
1676
|
-
|
|
1691
|
+
function projectWithStart(items: ReturnType<typeof projectItems>, clock: string | undefined, forked = false): ReturnType<typeof projectItems> {
|
|
1692
|
+
// The label is part of rev: a view keyed on rev alone would keep "session" after switching into a fork.
|
|
1693
|
+
const label = `${forked ? "forked" : "session"} · ${clock}`;
|
|
1694
|
+
return clock ? [{ kind: "segment", id: "session-start", rev: label, label }, ...items] : items;
|
|
1677
1695
|
}
|
|
1678
1696
|
|
|
1679
1697
|
/** "~/Projects/workspace/xeno-tui" → "~/P/w/xeno-tui": every directory but the last cut to its first letter. */
|
package/src/apps/ui/project.ts
CHANGED
|
@@ -123,5 +123,5 @@ function shortRef(ref: string): string { return ref.split("/").pop() ?? ref; }
|
|
|
123
123
|
|
|
124
124
|
/** What the view needs of a logged image: its address, name and pixel size. Never the bytes (D8). */
|
|
125
125
|
export function imageView(image: ImageRef): ImageView {
|
|
126
|
-
return { sha256: image.sha256, name: image.name, width: image.width, height: image.height };
|
|
126
|
+
return { sha256: image.sha256, name: image.name, width: image.width, height: image.height, ...(image.original ? { full: image.original.sha256 } : {}) };
|
|
127
127
|
}
|
|
@@ -102,13 +102,15 @@ export async function pickStartupSession(opts: { workspace: string; sessionDir?:
|
|
|
102
102
|
let scope: "current" | "all" = "current";
|
|
103
103
|
const items = (query = ""): ListItem[] => selectSessions(rows, query, "threaded", false, (q, text) => { const score = fuzzyMatch(text, q); return { matches: score !== null, score: score ?? Infinity }; })
|
|
104
104
|
.map(row => ({ value: row.path, label: " ".repeat(Math.min(8, row.depth)) + (row.depth ? "↳ " : "") + (row.name?.trim() || row.text?.split("\n").find(Boolean)?.slice(0, 90) || row.id),
|
|
105
|
-
description: [row.updated_at?.slice(0, 16).replace("T", " ") ?? "", row.
|
|
105
|
+
description: [row.updated_at?.slice(0, 16).replace("T", " ") ?? "", row.forked_at ? `forked ${row.forked_at.slice(0, 16).replace("T", " ")}` : "",
|
|
106
|
+
row.model ?? "", scope === "all" ? row.workspace ?? "" : ""].filter(Boolean).join(" · ") }));
|
|
106
107
|
try {
|
|
107
108
|
let spec: DialogSpec & { kind: "list" } | undefined;
|
|
108
109
|
const load = async () => {
|
|
109
110
|
rows = [];
|
|
110
111
|
for await (const batch of scanSessionRoots(scope === "current" ? [root] : await sessionRoots(root), scan.signal)) {
|
|
111
|
-
|
|
112
|
+
// A session left without a word is not something to resume (fix-skip-empty-sessions).
|
|
113
|
+
rows.push(...batch.sessions.filter((row): row is Session => row.ok && row.conversation !== false));
|
|
112
114
|
if (spec && host.dialogs.top === spec) host.dialogs.list?.setItems(items(host.dialogs.list.query));
|
|
113
115
|
}
|
|
114
116
|
};
|
package/src/apps/ui/surface.ts
CHANGED
|
@@ -20,6 +20,8 @@ export interface TurnSurface {
|
|
|
20
20
|
pushProgress?(progress: NonNullable<LoopEvent["progress"]>): void;
|
|
21
21
|
pullQueued?(phase: Phase): Promise<UserInput | null>;
|
|
22
22
|
observeEvent?(event: LoopEvent): void;
|
|
23
|
+
/** A plain-text notice in the message stream (the interactive app); line mode prints instead. */
|
|
24
|
+
notice?(text: string, tone?: "info" | "success" | "warning" | "error"): void;
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
export interface Ui extends TurnSurface {
|
|
@@ -9,9 +9,24 @@ export interface ActivityFacts {
|
|
|
9
9
|
readonly goal?: string;
|
|
10
10
|
readonly phase?: "connecting" | "thinking" | "streaming" | "tool";
|
|
11
11
|
readonly tool?: string;
|
|
12
|
-
readonly retry?:
|
|
12
|
+
readonly retry?: RetryFacts;
|
|
13
13
|
readonly queued: number;
|
|
14
14
|
}
|
|
15
|
+
/** One pending retry: which attempt, out of how many, how long until it, and why. */
|
|
16
|
+
export interface RetryFacts { readonly attempt: number; readonly max?: number; readonly seconds: number; readonly reason?: string }
|
|
17
|
+
|
|
18
|
+
const attemptText = (retry: RetryFacts): string => retry.max === undefined ? `${retry.attempt}` : `${retry.attempt}/${retry.max}`;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The stream-area retry line. The cause appears from the third attempt (or
|
|
22
|
+
* the last, for a smaller budget), as in Claude Code: one or two retries are
|
|
23
|
+
* noise, and naming them would send the user to check a network that is fine.
|
|
24
|
+
*/
|
|
25
|
+
export function retryLine(retry: RetryFacts): string {
|
|
26
|
+
const named = retry.reason !== undefined && retry.attempt >= Math.min(3, retry.max ?? 3);
|
|
27
|
+
return `retry ${attemptText(retry)} in ${Math.max(0, retry.seconds).toFixed(1)}s · ${named ? `${retry.reason} · ` : ""}esc to cancel`;
|
|
28
|
+
}
|
|
29
|
+
|
|
15
30
|
export interface ActivityStatus { readonly state: ProgramStatus["state"]; readonly label: string; readonly detail: string; readonly queued: number }
|
|
16
31
|
|
|
17
32
|
const STOP_CAUSE: Partial<Record<ExecutionOutcome["reason"], string>> = {
|
|
@@ -29,7 +44,7 @@ export function activityStatus(facts: ActivityFacts): ActivityStatus {
|
|
|
29
44
|
: !facts.waiting && facts.goal === "awaiting_human" ? "Awaiting review" : "Awaiting input");
|
|
30
45
|
if (status.state === "error") return view("Failed");
|
|
31
46
|
if (!facts.running) return view(status.state === "done" ? "Turn finished" : facts.outcome?.status === "cancelled" ? "Cancelled" : "Ready");
|
|
32
|
-
if (facts.retry) return view("Retrying", `attempt ${facts.retry
|
|
47
|
+
if (facts.retry) return view("Retrying", `attempt ${attemptText(facts.retry)} · ${Math.max(0, Math.ceil(facts.retry.seconds))}s`);
|
|
33
48
|
if (facts.phase === "tool") return view("Running tool", facts.tool ?? "Tool execution");
|
|
34
49
|
if (facts.phase === "thinking") return view("Thinking");
|
|
35
50
|
if (facts.phase === "streaming") return view("Responding");
|
|
@@ -65,18 +65,12 @@ const NEGATIVE_ANSWERS: Readonly<Record<string, string>> = {
|
|
|
65
65
|
};
|
|
66
66
|
|
|
67
67
|
/**
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
* not classified. Under pipefail an earlier pipeline stage can also set the
|
|
73
|
-
* status; the exit is kept in the receipt either way, and the label states
|
|
74
|
-
* the program's meaning rather than claiming success. Anything this scanner
|
|
75
|
-
* cannot read with certainty -- unbalanced quotes, subshells, compound
|
|
76
|
-
* statements, substitutions -- yields null.
|
|
68
|
+
* The final simple command of `command` and the separator before it, or
|
|
69
|
+
* null when this scanner cannot read the command with certainty --
|
|
70
|
+
* unbalanced quotes, subshells, compound statements, substitutions or a
|
|
71
|
+
* background `&`. Only top-level `;`, newline, `&&`, `||` and `|` split.
|
|
77
72
|
*/
|
|
78
|
-
|
|
79
|
-
if (exit !== 1) return null;
|
|
73
|
+
function finalStage(command: string): { separator: string; words: string[] } | null {
|
|
80
74
|
let quote: "'" | "\"" | null = null, start = 0, separator = "";
|
|
81
75
|
for (let i = 0; i < command.length; i++) {
|
|
82
76
|
const c = command[i]!;
|
|
@@ -93,16 +87,64 @@ export function negativeAnswer(command: string, exit: number): string | null {
|
|
|
93
87
|
if (c === ";" || c === "\n" || c === "|") { separator = c; start = i + 1; continue; }
|
|
94
88
|
if (c === "&" && command[i + 1] !== ">" && command[i - 1] !== ">") return null;
|
|
95
89
|
}
|
|
96
|
-
if (quote !== null
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
90
|
+
if (quote !== null) return null;
|
|
91
|
+
return { separator, words: command.slice(start).trim().split(/\s+/).filter(word => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const programName = (words: readonly string[]) => (words[0] ?? "").replace(/^.*\//, "");
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* What exit 1 of the command's final simple command means, or null.
|
|
98
|
+
*
|
|
99
|
+
* Only the last segment after a top-level `;`, newline, `||` or `|` is read.
|
|
100
|
+
* After `&&` the 1 may come from the command that short-circuited, so it is
|
|
101
|
+
* not classified. Under pipefail an earlier pipeline stage can also set the
|
|
102
|
+
* status; the exit is kept in the receipt either way, and the label states
|
|
103
|
+
* the program's meaning rather than claiming success. Anything this scanner
|
|
104
|
+
* cannot read with certainty -- unbalanced quotes, subshells, compound
|
|
105
|
+
* statements, substitutions -- yields null.
|
|
106
|
+
*/
|
|
107
|
+
export function negativeAnswer(command: string, exit: number): string | null {
|
|
108
|
+
if (exit !== 1) return null;
|
|
109
|
+
const stage = finalStage(command);
|
|
110
|
+
if (stage === null || stage.separator === "&&") return null;
|
|
111
|
+
const program = programName(stage.words);
|
|
112
|
+
if (program === "git") return stage.words[1] === "grep" ? "no match" : null;
|
|
100
113
|
return Object.hasOwn(NEGATIVE_ANSWERS, program) ? NEGATIVE_ANSWERS[program]! : null;
|
|
101
114
|
}
|
|
102
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Readers that may stop reading before their input ends (`head -n`, `sed Nq`,
|
|
118
|
+
* `awk '{exit}'`, `grep -m` / `-q`), closing the pipe on the writer before it.
|
|
119
|
+
*/
|
|
120
|
+
const EARLY_READERS: ReadonlySet<string> = new Set(["head", "sed", "awk", "gawk", "mawk", "grep", "egrep", "fgrep", "rg"]);
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* What exit 141 means when an early reader ends the pipeline, or null.
|
|
124
|
+
*
|
|
125
|
+
* Measured (pipe-early-close card): under `bash -o pipefail`, `seq 1 1000000
|
|
126
|
+
* | head -1` exits 141 -- pipefail reports the rightmost nonzero stage, here
|
|
127
|
+
* the writer that SIGPIPE killed after head closed its input, while every
|
|
128
|
+
* stage to its right, the final reader included, exited 0. The output the
|
|
129
|
+
* command asked for is complete. Only a final stage joined by a top-level
|
|
130
|
+
* `|` is read: after `;`, `&&` or `||` the 141 is the last command's own.
|
|
131
|
+
*/
|
|
132
|
+
export function earlyCloseAnswer(command: string, exit: number): string | null {
|
|
133
|
+
if (exit !== 141) return null;
|
|
134
|
+
const stage = finalStage(command);
|
|
135
|
+
if (stage === null || stage.separator !== "|") return null;
|
|
136
|
+
const program = programName(stage.words);
|
|
137
|
+
return EARLY_READERS.has(program) ? `${program} closed the pipe early; the earlier stage's SIGPIPE is not a failure` : null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The program's own meaning of a nonzero exit that is an answer, not a fault. */
|
|
141
|
+
export function commandAnswer(command: string, exit: number): string | null {
|
|
142
|
+
return negativeAnswer(command, exit) ?? earlyCloseAnswer(command, exit);
|
|
143
|
+
}
|
|
144
|
+
|
|
103
145
|
/** A trap can exit zero after cancellation: successful exit alone does not erase the stop. */
|
|
104
146
|
export function commandCompletion(exit: number, stopped?: "cancelled" | "timeout", cleanup?: ToolDiagnostic, command?: string) {
|
|
105
|
-
const answered = exit === 0 || command !== undefined &&
|
|
147
|
+
const answered = exit === 0 || command !== undefined && commandAnswer(command, exit) !== null;
|
|
106
148
|
return { ok: stopped === undefined && answered && cleanup === undefined,
|
|
107
149
|
outcome: executionOutcome(stopped ?? (answered && cleanup === undefined ? "tool_completed" : "tool_failed")),
|
|
108
150
|
...(cleanup ? { warnings: [cleanup] } : {}) };
|
|
@@ -69,7 +69,7 @@ export function requestCompletion(state: RequestState, remainingRequests: number
|
|
|
69
69
|
if (state.used_tools && text.length === 0) gaps.push("the final reply is empty");
|
|
70
70
|
// Strict verification counts only configured gates (O.11); say so, since a successful `cat` would otherwise look like a check.
|
|
71
71
|
if (freshCheck && freshCheck.status !== "pass") gaps.push(freshCheck.gate_only
|
|
72
|
-
? "files were changed but no configured verification gate (a test, typecheck, lint, build or check command) has passed since the last edit; reading or printing files does not count, and a gate counts only when run as
|
|
72
|
+
? "files were changed but no configured verification gate (a test, typecheck, lint, build or check command) has passed since the last edit; reading or printing files does not count, and a gate counts only when run as plain commands joined by && (trailing output redirects to fixed files are fine); ;, ||, pipes, $() and variables hide it"
|
|
73
73
|
: "files were changed but no command has succeeded since the last edit");
|
|
74
74
|
if (acceptance?.status === "needs_work") gaps.push(`declared acceptance criteria need evidence: ${[
|
|
75
75
|
...missingCommands.map(item => `${item.id}=${item.status}`),
|
package/src/core/derive.ts
CHANGED
|
@@ -425,7 +425,15 @@ export function deriveHandoff(input: DeriveInput): Handoff {
|
|
|
425
425
|
const processes = deriveProcesses(prev?.processes ?? [], events, input.self.node);
|
|
426
426
|
|
|
427
427
|
const hasRemaining = inFlight.length > 0 || blocked.length > 0 || openQuestions.length > 0;
|
|
428
|
-
|
|
428
|
+
// A next action is a statement about the current state of the work, so it
|
|
429
|
+
// follows the evidence rule above: only one this segment recorded or
|
|
430
|
+
// refreshed counts. A carried one stays listed but no longer suppresses the
|
|
431
|
+
// boundary and continuation reminders that ask the model to confirm it.
|
|
432
|
+
const thisSegment = new Set(events.map((e) => e.id));
|
|
433
|
+
const freshUnscoped = new Set(events.flatMap((e) => e.t === "next_action" && e.work_id === undefined ? [e.what] : []));
|
|
434
|
+
const freshWork = new Set(workItems.filter((item) => thisSegment.has(item.updated_at)).map((item) => item.id));
|
|
435
|
+
const hasNextAction = inFlight.some((i) => i.next_action.trim().length > 0 &&
|
|
436
|
+
(i.work_id === undefined ? freshUnscoped.has(i.what) : freshWork.has(i.work_id)));
|
|
429
437
|
|
|
430
438
|
return {
|
|
431
439
|
handoff_version: HANDOFF_VERSION,
|
|
@@ -25,6 +25,14 @@ export function validNodeRef(value: unknown): value is NodeRef {
|
|
|
25
25
|
(value["at_event"] === undefined || typeof value["at_event"] === "string");
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/** A label is a short phrase or class name; anything longer or with control characters is not one the harness wrote. */
|
|
29
|
+
function validFailureCause(value: unknown): boolean {
|
|
30
|
+
return isRecord(value) && (value["source"] === "provider" || value["source"] === "harness") &&
|
|
31
|
+
typeof value["label"] === "string" && /^[^\u0000-\u001f\u007f]{1,64}$/.test(value["label"]) &&
|
|
32
|
+
(value["status"] === undefined || Number.isSafeInteger(value["status"])) &&
|
|
33
|
+
(value["retries"] === undefined || natural(value["retries"]));
|
|
34
|
+
}
|
|
35
|
+
|
|
28
36
|
export function isLogEvent(value: unknown): value is LogEvent {
|
|
29
37
|
if (!isRecord(value) || typeof value["id"] !== "string" || value["id"].length === 0 || typeof value["at"] !== "string") return false;
|
|
30
38
|
if (value["outcome"] !== undefined && !isExecutionOutcome(value["outcome"])) return false;
|
|
@@ -58,6 +66,14 @@ export function isLogEvent(value: unknown): value is LogEvent {
|
|
|
58
66
|
value["reason"] === "disposed" && isRecord(output) && typeof output["path"] === "string" &&
|
|
59
67
|
typeof output["sha256"] === "string" && /^[a-f0-9]{64}$/.test(output["sha256"]) && natural(output["bytes"]);
|
|
60
68
|
}
|
|
69
|
+
case "approval": {
|
|
70
|
+
const user = ["once", "command", "tool", "deny"].includes(String(value["decision"]));
|
|
71
|
+
const rule = value["decision"] === "allow" || value["decision"] === "deny";
|
|
72
|
+
return has("call_id", "tool") && value["call_id"] !== "" && value["tool"] !== "" &&
|
|
73
|
+
optionalString(value["command"]) && optionalString(value["rule"]) &&
|
|
74
|
+
(value["source"] === "user" ? user : value["source"] === "no_prompt" ? value["decision"] === "deny" :
|
|
75
|
+
["session_rule", "config", "auto"].includes(String(value["source"])) && rule);
|
|
76
|
+
}
|
|
61
77
|
case "skill_loaded": return has("name", "path", "sha256", "artifact") &&
|
|
62
78
|
["sha256", "artifact"].every(key => /^[a-f0-9]{64}$/.test(String(value[key]))) &&
|
|
63
79
|
["inline", "fork", "read"].includes(String(value["mode"])) && ["user", "model"].includes(String(value["origin"]));
|
|
@@ -81,7 +97,7 @@ export function isLogEvent(value: unknown): value is LogEvent {
|
|
|
81
97
|
}
|
|
82
98
|
case "extension": return has("customType", "content") && typeof value["display"] === "boolean" && typeof value["inContext"] === "boolean" && (value["data"] === undefined || isJsonValue(value["data"]));
|
|
83
99
|
case "user": return has("text") && (value["request"] === undefined || typeof value["request"] === "string" && value["request"] !== "");
|
|
84
|
-
case "outcome": return has("reason") && isExecutionOutcome(value["outcome"]);
|
|
100
|
+
case "outcome": return has("reason") && isExecutionOutcome(value["outcome"]) && (value["failure"] === undefined || validFailureCause(value["failure"]));
|
|
85
101
|
case "user_bash": {
|
|
86
102
|
const ref = value["content_ref"];
|
|
87
103
|
const outcome = value["outcome"];
|
|
@@ -92,7 +108,7 @@ export function isLogEvent(value: unknown): value is LogEvent {
|
|
|
92
108
|
isRecord(ref) && typeof ref["sha256"] === "string" &&
|
|
93
109
|
typeof ref["path"] === "string" && natural(ref["bytes"]);
|
|
94
110
|
}
|
|
95
|
-
case "guidance": return has("text") && ["continuation", "repetition", "budget", "boundary", "stall", "output_recovery", "completion_review", "churn", "deadline", "runtime_acceptance", "goal"].includes(String(value["reason"])) && strings(value["sources"]);
|
|
111
|
+
case "guidance": return has("text") && ["continuation", "repetition", "budget", "boundary", "stall", "output_recovery", "completion_review", "churn", "deadline", "runtime_acceptance", "goal", "secret"].includes(String(value["reason"])) && strings(value["sources"]);
|
|
96
112
|
case "interrupt": return has("user_id");
|
|
97
113
|
case "assistant": return (value["stop_reason"] === null || optionalString(value["stop_reason"])) &&
|
|
98
114
|
(value["replay_key"] === undefined || typeof value["replay_key"] === "string" && /^[a-f0-9]{64}$/.test(value["replay_key"])) &&
|
|
@@ -156,7 +172,7 @@ export function isLogEvent(value: unknown): value is LogEvent {
|
|
|
156
172
|
return effect["kind"] === "edit" && typeof effect["path"] === "string" && typeof effect["sha256_after"] === "string" &&
|
|
157
173
|
(effect["sha256_before"] === null || typeof effect["sha256_before"] === "string");
|
|
158
174
|
}
|
|
159
|
-
case "cut": return has("handoff_ref") && (value["working_set_version"] === undefined || value["working_set_version"] === 2) && ["trigger", "hard_limit", "model_checkpoint"].includes(String(value["reason"])) &&
|
|
175
|
+
case "cut": return has("handoff_ref") && (value["working_set_version"] === undefined || value["working_set_version"] === 2 || value["working_set_version"] === 3) && ["trigger", "hard_limit", "model_checkpoint"].includes(String(value["reason"])) &&
|
|
160
176
|
(value["guidance_tools"] === undefined || Array.isArray(value["guidance_tools"]) && value["guidance_tools"].every(item => typeof item === "string")) &&
|
|
161
177
|
optionalString(value["retained_from"]) && optionalString(value["next_context_ref"]) &&
|
|
162
178
|
(value["next_node"] === undefined || natural(value["next_node"])) && optionalString(value["next_model"]) &&
|