jeopi-cli 16.4.5 → 16.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/sdk.ts CHANGED
@@ -107,6 +107,7 @@ import {
107
107
  import { AgentSession } from "./session/agent-session";
108
108
  import { discoverAuthStorage as discoverAuthStorageFromConfig } from "./session/auth-broker-config";
109
109
  import type { AuthStorage } from "./session/auth-storage";
110
+ import { createInterruptedTurnAbortMessage } from "./session/exit-diagnostics";
110
111
  import {
111
112
  type CustomMessage,
112
113
  convertToLlm,
@@ -1235,11 +1236,19 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1235
1236
  }
1236
1237
  const secretsEnabled = obfuscator?.hasSecrets() === true;
1237
1238
 
1238
- // Check if session has existing data to restore
1239
- const existingSession = logger.time("loadSessionContext", () =>
1239
+ // An abnormal process exit after a non-terminal message tail is durable
1240
+ // evidence that the old process can no longer finish that turn. Preserve the
1241
+ // partial transcript and append one terminal aborted assistant record before
1242
+ // rebuilding runtime context. The helper is idempotent once that record exists.
1243
+ let existingBranch = logger.time("getSessionBranch", () => sessionManager.getBranch());
1244
+ const interruptedTurnAbort = createInterruptedTurnAbortMessage(existingBranch);
1245
+ if (interruptedTurnAbort) {
1246
+ sessionManager.appendMessage(interruptedTurnAbort);
1247
+ existingBranch = logger.time("getRecoveredSessionBranch", () => sessionManager.getBranch());
1248
+ }
1249
+ let existingSession = logger.time("loadSessionContext", () =>
1240
1250
  deobfuscateSessionContext(sessionManager.buildSessionContext(), obfuscator),
1241
1251
  );
1242
- const existingBranch = logger.time("getSessionBranch", () => sessionManager.getBranch());
1243
1252
  const hasExistingSession = existingBranch.length > 0;
1244
1253
  const hasThinkingEntry = existingBranch.some(entry => entry.type === "thinking_level_change");
1245
1254
  const hasServiceTierEntry = existingBranch.some(entry => entry.type === "service_tier_change");
@@ -2029,6 +2038,24 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2029
2038
  }
2030
2039
  }
2031
2040
 
2041
+ // A first-turn user tail has no assistant metadata to copy. Once startup
2042
+ // has selected its final model, use that model to terminate the
2043
+ // interrupted turn before the live agent consumes the restored context.
2044
+ if (model) {
2045
+ const selectedModelAbort = createInterruptedTurnAbortMessage(existingBranch, {
2046
+ api: model.api,
2047
+ provider: model.provider,
2048
+ model: model.id,
2049
+ });
2050
+ if (selectedModelAbort) {
2051
+ sessionManager.appendMessage(selectedModelAbort);
2052
+ existingBranch = logger.time("getRecoveredUserTailBranch", () => sessionManager.getBranch());
2053
+ existingSession = logger.time("loadRecoveredUserTailContext", () =>
2054
+ deobfuscateSessionContext(sessionManager.buildSessionContext(), obfuscator),
2055
+ );
2056
+ }
2057
+ }
2058
+
2032
2059
  // Discover custom commands (TypeScript slash commands)
2033
2060
  const customCommandsResult: CustomCommandsLoadResult = options.disableExtensionDiscovery
2034
2061
  ? { commands: [], errors: [] }
@@ -331,6 +331,7 @@ import {
331
331
  import { findCompactMode } from "./compact-modes";
332
332
  import {
333
333
  collectPendingToolCalls,
334
+ createInterruptedTurnAbortMessage,
334
335
  SESSION_EXIT_CUSTOM_TYPE,
335
336
  type SessionExitData,
336
337
  summarizeToolArguments,
@@ -14555,7 +14556,7 @@ export class AgentSession {
14555
14556
  this.#rekeyHindsightMemoryForCurrentSessionId();
14556
14557
  this.#rekeyMnemopiMemoryForCurrentSessionId();
14557
14558
 
14558
- const sessionContext = this.buildDisplaySessionContext();
14559
+ let sessionContext = this.buildDisplaySessionContext();
14559
14560
  const didReloadConversationChange =
14560
14561
  previousSessionContext !== undefined &&
14561
14562
  this.#didSessionMessagesChange(previousSessionContext.messages, sessionContext.messages);
@@ -14613,6 +14614,20 @@ export class AgentSession {
14613
14614
  }
14614
14615
  }
14615
14616
 
14617
+ const model = this.model;
14618
+ if (model) {
14619
+ const interruptedTurnAbort = createInterruptedTurnAbortMessage(this.sessionManager.getBranch(), {
14620
+ api: model.api,
14621
+ provider: model.provider,
14622
+ model: model.id,
14623
+ });
14624
+ if (interruptedTurnAbort) {
14625
+ this.sessionManager.appendMessage(interruptedTurnAbort);
14626
+ sessionContext = this.buildDisplaySessionContext();
14627
+ this.agent.replaceMessages(sessionContext.messages);
14628
+ }
14629
+ }
14630
+
14616
14631
  const hasThinkingEntry = this.sessionManager.getBranch().some(entry => entry.type === "thinking_level_change");
14617
14632
  const hasServiceTierEntry = this.sessionManager
14618
14633
  .getBranch()
@@ -5,6 +5,9 @@ import { isEnoent, logger } from "jeopi-utils";
5
5
 
6
6
  const BLOB_PREFIX = "blob:sha256:";
7
7
 
8
+ /** Canonical blob suffix: a 64-char lowercase hex SHA-256 digest, nothing else. */
9
+ const BLOB_HASH_RE = /^[a-f0-9]{64}$/;
10
+
8
11
  export interface BlobPutOptions {
9
12
  /** Optional file extension for a sidecar hardlink/copy that OS openers can type-detect. */
10
13
  extension?: string;
@@ -179,10 +182,24 @@ export function isBlobRef(data: string): boolean {
179
182
  return data.startsWith(BLOB_PREFIX);
180
183
  }
181
184
 
182
- /** Extract the SHA-256 hash from a blob reference string. */
185
+ /**
186
+ * Extract the SHA-256 hash from a blob reference string.
187
+ *
188
+ * Returns null when the string is not a blob ref, or when the suffix is not a
189
+ * canonical 64-char lowercase hex hash. Rejecting non-hash suffixes here is the
190
+ * single choke point that keeps every resolution path confined to the blob dir:
191
+ * `get`/`getSync`/`has` feed this value into `path.join(this.dir, hash)`, so an
192
+ * unvalidated `../` suffix in a crafted session file would otherwise escape the
193
+ * store and read arbitrary files.
194
+ */
183
195
  export function parseBlobRef(data: string): string | null {
184
196
  if (!data.startsWith(BLOB_PREFIX)) return null;
185
- return data.slice(BLOB_PREFIX.length);
197
+ const hash = data.slice(BLOB_PREFIX.length);
198
+ if (!BLOB_HASH_RE.test(hash)) {
199
+ logger.warn("Rejected malformed blob reference", { suffix: hash });
200
+ return null;
201
+ }
202
+ return hash;
186
203
  }
187
204
 
188
205
  /** Identify provider transport image data URLs so persistence can externalize and restore them losslessly. */
@@ -1,4 +1,5 @@
1
1
  import type { AgentMessage } from "jeopi-agent-core";
2
+ import type { AssistantMessage } from "jeopi-ai";
2
3
  import type { SessionEntry } from "./session-entries";
3
4
 
4
5
  export const TOOL_EXECUTION_START_CUSTOM_TYPE = "tool_execution_start";
@@ -53,11 +54,116 @@ interface ToolCallContent {
53
54
  arguments?: unknown;
54
55
  }
55
56
 
57
+ export interface AssistantModelMetadata {
58
+ api: AssistantMessage["api"];
59
+ provider: string;
60
+ model: string;
61
+ }
62
+
56
63
  function isObject(value: unknown): value is Record<string, unknown> {
57
64
  if (typeof value !== "object") return false;
58
65
  return value !== null;
59
66
  }
60
67
 
68
+ function isPendingToolCallDiagnostic(value: unknown): value is PendingToolCallDiagnostic {
69
+ if (!isObject(value) || typeof value.toolName !== "string") return false;
70
+ if ("toolCallId" in value && typeof value.toolCallId !== "string") return false;
71
+ if ("intent" in value && typeof value.intent !== "string") return false;
72
+ if ("assistantTimestamp" in value && typeof value.assistantTimestamp !== "number") return false;
73
+ if ("startedAt" in value && typeof value.startedAt !== "string") return false;
74
+ return true;
75
+ }
76
+
77
+ function readSessionExit(entry: SessionEntry): SessionExitData | undefined {
78
+ if (entry.type !== "custom" || entry.customType !== SESSION_EXIT_CUSTOM_TYPE || !isObject(entry.data)) {
79
+ return undefined;
80
+ }
81
+ const { reason, kind, recordedAt } = entry.data;
82
+ if (
83
+ typeof reason !== "string" ||
84
+ (kind !== "normal" && kind !== "signal" && kind !== "fatal" && kind !== "process_exit") ||
85
+ typeof recordedAt !== "string"
86
+ ) {
87
+ return undefined;
88
+ }
89
+ const rawPending = entry.data.pendingToolCalls;
90
+ return {
91
+ reason,
92
+ kind,
93
+ recordedAt,
94
+ pendingToolCalls:
95
+ Array.isArray(rawPending) && rawPending.every(isPendingToolCallDiagnostic) ? rawPending : undefined,
96
+ };
97
+ }
98
+
99
+ /**
100
+ * createInterruptedTurnAbortMessage returns a terminal assistant record when
101
+ * the latest persisted process exit follows a non-terminal conversation tail.
102
+ */
103
+ export function createInterruptedTurnAbortMessage(
104
+ entries: readonly SessionEntry[],
105
+ fallbackModel?: AssistantModelMetadata,
106
+ ): AssistantMessage | undefined {
107
+ let exitIndex = -1;
108
+ let exit: SessionExitData | undefined;
109
+ for (let index = entries.length - 1; index >= 0; index--) {
110
+ const candidate = readSessionExit(entries[index]!);
111
+ if (!candidate) continue;
112
+ exitIndex = index;
113
+ exit = candidate;
114
+ break;
115
+ }
116
+ if (!exit || (exit.kind === "normal" && !exit.pendingToolCalls?.length)) return undefined;
117
+
118
+ let tailIndex = -1;
119
+ let tail: AgentMessage | undefined;
120
+ for (let index = entries.length - 1; index >= 0; index--) {
121
+ const entry = entries[index]!;
122
+ if (entry.type !== "message") continue;
123
+ tailIndex = index;
124
+ tail = entry.message;
125
+ break;
126
+ }
127
+ if (!tail || tailIndex > exitIndex) return undefined;
128
+ if (tail.role === "assistant" && !tail.content.some(isToolCallContent)) return undefined;
129
+
130
+ let previousAssistant: AssistantMessage | undefined;
131
+ for (let index = tailIndex; index >= 0; index--) {
132
+ const entry = entries[index]!;
133
+ if (entry.type !== "message" || entry.message.role !== "assistant") continue;
134
+ previousAssistant = entry.message;
135
+ break;
136
+ }
137
+ if (
138
+ tail.role === "toolResult" &&
139
+ (previousAssistant?.stopReason === "error" || previousAssistant?.stopReason === "aborted")
140
+ ) {
141
+ return undefined;
142
+ }
143
+ const model = previousAssistant ?? fallbackModel;
144
+ if (!model) return undefined;
145
+
146
+ const recordedAt = Date.parse(exit.recordedAt);
147
+ return {
148
+ role: "assistant",
149
+ content: [],
150
+ api: model.api,
151
+ provider: model.provider,
152
+ model: model.model,
153
+ usage: {
154
+ input: 0,
155
+ output: 0,
156
+ cacheRead: 0,
157
+ cacheWrite: 0,
158
+ totalTokens: 0,
159
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
160
+ },
161
+ stopReason: "aborted",
162
+ errorMessage: "Previous jeopi process exited before completing the turn.",
163
+ timestamp: Number.isFinite(recordedAt) ? recordedAt : Date.now(),
164
+ };
165
+ }
166
+
61
167
  function isToolCallContent(value: unknown): value is ToolCallContent {
62
168
  if (!isObject(value)) return false;
63
169
  return value.type === "toolCall" && (typeof value.name === "string" || typeof value.id === "string");