@cjhyy/code-shell-core 0.8.7 → 0.8.9
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/dist/cli/agent-server-stdio.js +7 -5
- package/dist/context/manager.js +10 -2
- package/dist/engine/engine.d.ts +60 -8
- package/dist/engine/engine.js +187 -35
- package/dist/engine/run-finalize.js +8 -6
- package/dist/engine/run-session-open.js +1 -1
- package/dist/engine/run-setup.d.ts +5 -0
- package/dist/engine/run-setup.js +6 -1
- package/dist/engine/run-tooling.js +7 -1
- package/dist/engine/run-types.d.ts +31 -0
- package/dist/engine/run-types.js +21 -0
- package/dist/engine/run-workspace.js +11 -5
- package/dist/engine/turn-loop.d.ts +0 -3
- package/dist/engine/turn-loop.js +1 -80
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/panel-apps/manifest.d.ts +11 -11
- package/dist/panel-apps/manifest.js +7 -3
- package/dist/prompt/composer.d.ts +15 -0
- package/dist/prompt/composer.js +23 -8
- package/dist/protocol/chat-session-manager.js +9 -0
- package/dist/protocol/server.js +77 -1
- package/dist/protocol/types.d.ts +26 -0
- package/dist/session/memory.d.ts +10 -0
- package/dist/session/memory.js +7 -1
- package/dist/session/session-manager.d.ts +3 -1
- package/dist/session/session-manager.js +42 -25
- package/dist/session/transcript.d.ts +36 -0
- package/dist/session/transcript.js +149 -4
- package/dist/tool-system/builtin/index.js +4 -1
- package/dist/tool-system/path-policy.js +25 -33
- package/dist/types.d.ts +1 -1
- package/package.json +1 -1
|
@@ -148,25 +148,34 @@ function adoptCompatibilityGoalMutation(state) {
|
|
|
148
148
|
// disk. The storage root remains part of the key to preserve identity/data-root
|
|
149
149
|
// isolation.
|
|
150
150
|
const processLocalSessionBundles = new Map();
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
"
|
|
160
|
-
"
|
|
161
|
-
"
|
|
162
|
-
"
|
|
163
|
-
"
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
"
|
|
167
|
-
"
|
|
168
|
-
"
|
|
169
|
-
|
|
151
|
+
/**
|
|
152
|
+
* Every known transcript event must make an explicit fork decision. The
|
|
153
|
+
* `satisfies` constraint turns new TranscriptEventType additions into a
|
|
154
|
+
* compile-time error instead of a quick-chat failure discovered at runtime.
|
|
155
|
+
* The runtime fallback below still rejects unknown events from newer or
|
|
156
|
+
* malformed persisted transcripts.
|
|
157
|
+
*/
|
|
158
|
+
const FORK_EVENT_POLICY = {
|
|
159
|
+
message: "copy",
|
|
160
|
+
tool_use: "copy",
|
|
161
|
+
tool_result: "copy",
|
|
162
|
+
summary: "copy",
|
|
163
|
+
context_transfer: "copy",
|
|
164
|
+
range_archive: "copy",
|
|
165
|
+
content_replace: "copy",
|
|
166
|
+
file_history: "skip",
|
|
167
|
+
plan_operation: "skip",
|
|
168
|
+
session_meta: "skip",
|
|
169
|
+
subagent: "copy",
|
|
170
|
+
external_file_changes: "copy",
|
|
171
|
+
turn_boundary: "copy",
|
|
172
|
+
// Idempotency receipts belong to the source session and never contribute to
|
|
173
|
+
// model context. Copying them could replay a source response in the child.
|
|
174
|
+
run_result: "skip",
|
|
175
|
+
goal_progress: "copy",
|
|
176
|
+
turn_stopped: "copy",
|
|
177
|
+
error: "copy",
|
|
178
|
+
};
|
|
170
179
|
const FORK_STAGING_NAME = /^\.pending-fork-[A-Za-z0-9_.-]+-[A-Za-z0-9_-]{8}$/;
|
|
171
180
|
const FORK_STAGING_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
172
181
|
const FORK_STAGING_CLEANUP_LIMIT = 32;
|
|
@@ -365,7 +374,7 @@ export class SessionManager {
|
|
|
365
374
|
* Create a session. `qchat-` sessions stay process-local; ordinary sessions
|
|
366
375
|
* materialize state.json + transcript.jsonl before return.
|
|
367
376
|
*/
|
|
368
|
-
create(cwd, model, provider, explicitSessionId, parentSessionId, origin, kind = "work") {
|
|
377
|
+
create(cwd, model, provider, explicitSessionId, parentSessionId, origin, kind = "work", ephemeral = false) {
|
|
369
378
|
// External callers may pass any string; nanoid output is trusted. Either
|
|
370
379
|
// way the ID gets joined into a filesystem path, so the public entry
|
|
371
380
|
// point validates before that join.
|
|
@@ -393,7 +402,7 @@ export class SessionManager {
|
|
|
393
402
|
// new top-level session (key present, null) apart from a legacy session
|
|
394
403
|
// (key absent) and from a sub-agent (key present, non-empty string).
|
|
395
404
|
parentSessionId: parentSessionId ?? null,
|
|
396
|
-
...(sessionId.startsWith("qchat-") ? { ephemeral: true } : {}),
|
|
405
|
+
...(ephemeral || sessionId.startsWith("qchat-") ? { ephemeral: true } : {}),
|
|
397
406
|
...(origin ? { origin } : {}),
|
|
398
407
|
};
|
|
399
408
|
if (isEphemeralSessionState(state)) {
|
|
@@ -1048,6 +1057,10 @@ export class SessionManager {
|
|
|
1048
1057
|
return undefined;
|
|
1049
1058
|
}
|
|
1050
1059
|
}
|
|
1060
|
+
/** Whether a live or persisted Session is explicitly process-local. */
|
|
1061
|
+
isEphemeralSession(sessionId) {
|
|
1062
|
+
return this.readSessionState(sessionId)?.ephemeral === true;
|
|
1063
|
+
}
|
|
1051
1064
|
/**
|
|
1052
1065
|
* Merge a field-level state update into the latest persisted snapshot.
|
|
1053
1066
|
*
|
|
@@ -1568,7 +1581,10 @@ export class SessionManager {
|
|
|
1568
1581
|
return this.freezeForkSnapshot(sourceSessionId, sourceState, parsed.events, throughEventId, snapshotMode);
|
|
1569
1582
|
}
|
|
1570
1583
|
freezeForkSnapshot(sourceSessionId, sourceState, events, throughEventId, snapshotMode) {
|
|
1571
|
-
|
|
1584
|
+
// Both process-local and disk readers provide a snapshot array. Fork
|
|
1585
|
+
// selection is synchronous, so keep event references here and clone once
|
|
1586
|
+
// when the independently-owned target transcript is constructed below.
|
|
1587
|
+
const sourceEvents = [...events];
|
|
1572
1588
|
let frozen = sourceEvents;
|
|
1573
1589
|
const effectiveCursor = snapshotMode === "completed" ? sourceState.completedThroughEventId : throughEventId;
|
|
1574
1590
|
if (snapshotMode === "completed" && effectiveCursor === undefined) {
|
|
@@ -1599,12 +1615,13 @@ export class SessionManager {
|
|
|
1599
1615
|
}
|
|
1600
1616
|
const copiedEvents = [];
|
|
1601
1617
|
for (const event of frozen) {
|
|
1602
|
-
|
|
1618
|
+
const policy = FORK_EVENT_POLICY[event.type];
|
|
1619
|
+
if (policy === "skip")
|
|
1603
1620
|
continue;
|
|
1604
|
-
if (
|
|
1621
|
+
if (policy !== "copy") {
|
|
1605
1622
|
throw new SessionError(`Unsupported transcript event in fork: ${String(event.type)}`);
|
|
1606
1623
|
}
|
|
1607
|
-
copiedEvents.push(
|
|
1624
|
+
copiedEvents.push(event);
|
|
1608
1625
|
}
|
|
1609
1626
|
validateForkToolPairs(copiedEvents);
|
|
1610
1627
|
return { sourceState: structuredClone(sourceState), copiedEvents };
|
|
@@ -106,12 +106,48 @@ export declare class Transcript {
|
|
|
106
106
|
*/
|
|
107
107
|
appendTurnStopped(): TranscriptEvent | undefined;
|
|
108
108
|
appendSummary(summary: string, metadata: SummaryAppendMetadata): TranscriptEvent;
|
|
109
|
+
/**
|
|
110
|
+
* Persist a range-archival boundary. Span is [fromClientMessageId,
|
|
111
|
+
* toClientMessageId) over message events; an absent from means "from the
|
|
112
|
+
* beginning". Idempotent on segmentId so a crash-replayed closure cannot
|
|
113
|
+
* double-archive.
|
|
114
|
+
*/
|
|
115
|
+
appendRangeArchive(data: {
|
|
116
|
+
summary: string;
|
|
117
|
+
toClientMessageId: string;
|
|
118
|
+
fromClientMessageId?: string;
|
|
119
|
+
segmentId?: string;
|
|
120
|
+
}): TranscriptEvent | undefined;
|
|
109
121
|
appendError(error: string, details?: Record<string, unknown>): TranscriptEvent;
|
|
110
122
|
/**
|
|
111
123
|
* Derive Message[] from transcript events for sending to the LLM.
|
|
112
124
|
* This is the critical boundary: the LLM never sees the event log directly.
|
|
113
125
|
*/
|
|
114
126
|
toMessages(): Message[];
|
|
127
|
+
/**
|
|
128
|
+
* Marker-aware replay that ALSO reports, for every emitted message event
|
|
129
|
+
* carrying a clientMessageId, the LIVE index (its position in the returned
|
|
130
|
+
* messages array) of its FIRST emission. This is how the engine resolves an
|
|
131
|
+
* anchored archival window: raw transcript indices grow forever and go stale
|
|
132
|
+
* the moment a range_archive marker trims the replay, so any caller-held
|
|
133
|
+
* index range is meaningless — only client-message-id anchors resolved over
|
|
134
|
+
* THIS replay identify the right span.
|
|
135
|
+
*
|
|
136
|
+
* Contract details:
|
|
137
|
+
* - Messages dropped inside an archived span get NO index entry — they are
|
|
138
|
+
* not in the live list, so a window anchored on them cannot be built
|
|
139
|
+
* (the engine fails open in that case).
|
|
140
|
+
* - Only the FIRST emission of a duplicated clientMessageId is recorded
|
|
141
|
+
* (duplicates replay as plain messages per the one-shot span rule; the
|
|
142
|
+
* first index is the meaningful one).
|
|
143
|
+
* - The clientMessageId is deliberately NOT attached to the Message objects
|
|
144
|
+
* themselves: Message[] is exactly what gets serialized into LLM request
|
|
145
|
+
* payloads, and transport metadata must not leak into them.
|
|
146
|
+
*/
|
|
147
|
+
toMessagesWithIndex(): {
|
|
148
|
+
messages: Message[];
|
|
149
|
+
liveIndexByClientMessageId: Map<string, number>;
|
|
150
|
+
};
|
|
115
151
|
getEvents(type?: TranscriptEventType): TranscriptEvent[];
|
|
116
152
|
get turnNumber(): number;
|
|
117
153
|
get eventCount(): number;
|
|
@@ -12,6 +12,7 @@ const CONTEXT_EVENT_TYPES = new Set([
|
|
|
12
12
|
"tool_result",
|
|
13
13
|
"summary",
|
|
14
14
|
"context_transfer",
|
|
15
|
+
"range_archive",
|
|
15
16
|
]);
|
|
16
17
|
const INTERRUPTED_TOOL_RESULT_ERROR = "[Tool result missing due to interrupted session]";
|
|
17
18
|
function isSyntheticInterruptedToolResult(event) {
|
|
@@ -212,6 +213,19 @@ export class Transcript {
|
|
|
212
213
|
},
|
|
213
214
|
});
|
|
214
215
|
}
|
|
216
|
+
/**
|
|
217
|
+
* Persist a range-archival boundary. Span is [fromClientMessageId,
|
|
218
|
+
* toClientMessageId) over message events; an absent from means "from the
|
|
219
|
+
* beginning". Idempotent on segmentId so a crash-replayed closure cannot
|
|
220
|
+
* double-archive.
|
|
221
|
+
*/
|
|
222
|
+
appendRangeArchive(data) {
|
|
223
|
+
if (data.segmentId &&
|
|
224
|
+
this.events.some((e) => e.type === "range_archive" && e.data.segmentId === data.segmentId)) {
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
return this.append("range_archive", { ...data });
|
|
228
|
+
}
|
|
215
229
|
appendError(error, details) {
|
|
216
230
|
return this.append("error", { error, ...details });
|
|
217
231
|
}
|
|
@@ -220,12 +234,128 @@ export class Transcript {
|
|
|
220
234
|
* This is the critical boundary: the LLM never sees the event log directly.
|
|
221
235
|
*/
|
|
222
236
|
toMessages() {
|
|
237
|
+
return this.toMessagesWithIndex().messages;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Marker-aware replay that ALSO reports, for every emitted message event
|
|
241
|
+
* carrying a clientMessageId, the LIVE index (its position in the returned
|
|
242
|
+
* messages array) of its FIRST emission. This is how the engine resolves an
|
|
243
|
+
* anchored archival window: raw transcript indices grow forever and go stale
|
|
244
|
+
* the moment a range_archive marker trims the replay, so any caller-held
|
|
245
|
+
* index range is meaningless — only client-message-id anchors resolved over
|
|
246
|
+
* THIS replay identify the right span.
|
|
247
|
+
*
|
|
248
|
+
* Contract details:
|
|
249
|
+
* - Messages dropped inside an archived span get NO index entry — they are
|
|
250
|
+
* not in the live list, so a window anchored on them cannot be built
|
|
251
|
+
* (the engine fails open in that case).
|
|
252
|
+
* - Only the FIRST emission of a duplicated clientMessageId is recorded
|
|
253
|
+
* (duplicates replay as plain messages per the one-shot span rule; the
|
|
254
|
+
* first index is the meaningful one).
|
|
255
|
+
* - The clientMessageId is deliberately NOT attached to the Message objects
|
|
256
|
+
* themselves: Message[] is exactly what gets serialized into LLM request
|
|
257
|
+
* payloads, and transport metadata must not leak into them.
|
|
258
|
+
*/
|
|
259
|
+
toMessagesWithIndex() {
|
|
223
260
|
const messages = [];
|
|
261
|
+
const liveIndexByClientMessageId = new Map();
|
|
224
262
|
const selectedToolResults = preferredToolResults(this.events);
|
|
263
|
+
const hasRangeArchive = this.events.some((e) => e.type === "range_archive");
|
|
264
|
+
const spansByFromId = new Map();
|
|
265
|
+
let openingSpan;
|
|
266
|
+
if (hasRangeArchive) {
|
|
267
|
+
// First-occurrence event index per client message id, so a marker whose
|
|
268
|
+
// `to` does not come strictly after its `from` (out of order, or a
|
|
269
|
+
// degenerate from === to) can be rejected. Without this check the span
|
|
270
|
+
// opens at `from` but its close condition (`to`) was already passed
|
|
271
|
+
// while scanning forward, so it would never close — silently swallowing
|
|
272
|
+
// the rest of the conversation. Fail open instead: ignore the marker.
|
|
273
|
+
const firstIndexByClientId = new Map();
|
|
274
|
+
for (const [index, event] of this.events.entries()) {
|
|
275
|
+
if (event.type === "message" && typeof event.data.clientMessageId === "string") {
|
|
276
|
+
if (!firstIndexByClientId.has(event.data.clientMessageId)) {
|
|
277
|
+
firstIndexByClientId.set(event.data.clientMessageId, index);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
const presentClientIds = new Set(firstIndexByClientId.keys());
|
|
282
|
+
for (const event of this.events) {
|
|
283
|
+
if (event.type !== "range_archive")
|
|
284
|
+
continue;
|
|
285
|
+
const { summary, toClientMessageId, fromClientMessageId } = event.data;
|
|
286
|
+
if (typeof summary !== "string" || !presentClientIds.has(toClientMessageId))
|
|
287
|
+
continue;
|
|
288
|
+
if (fromClientMessageId === undefined) {
|
|
289
|
+
// Multiple from-less markers compete for this single opening-span
|
|
290
|
+
// slot; the LAST one wins (matching spansByFromId's Map.set
|
|
291
|
+
// semantics below). Last-wins is CORRECT by construction, not
|
|
292
|
+
// merely a tiebreak: the engine resolves a from-less archival
|
|
293
|
+
// window as [0, to) over the LIVE replay, so the window that
|
|
294
|
+
// produced a LATER from-less marker began with the EARLIER
|
|
295
|
+
// marker's replayed summary message, and summarizeRange merge-fed
|
|
296
|
+
// that prior summary (extractAnchoredSummary) into the new one.
|
|
297
|
+
// The later summary therefore already contains the earlier one's
|
|
298
|
+
// content — dropping the earlier marker here loses nothing. (And
|
|
299
|
+
// in production from-less windows only advance: each new marker
|
|
300
|
+
// ends at a later boundary, so the surviving span is the widest.)
|
|
301
|
+
openingSpan = { summary, toClientMessageId };
|
|
302
|
+
}
|
|
303
|
+
else if (presentClientIds.has(fromClientMessageId)) {
|
|
304
|
+
const fromIndex = firstIndexByClientId.get(fromClientMessageId);
|
|
305
|
+
const toIndex = firstIndexByClientId.get(toClientMessageId);
|
|
306
|
+
if (toIndex <= fromIndex)
|
|
307
|
+
continue; // out of order or degenerate: fail open
|
|
308
|
+
spansByFromId.set(fromClientMessageId, { summary, toClientMessageId });
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
let activeSpan = null;
|
|
313
|
+
if (openingSpan) {
|
|
314
|
+
activeSpan = openingSpan;
|
|
315
|
+
messages.push({ role: "user", content: openingSpan.summary });
|
|
316
|
+
}
|
|
317
|
+
// tool_use ids actually emitted into assistant message content blocks so
|
|
318
|
+
// far. A tool_result whose tool_use_id isn't in this set — e.g. because
|
|
319
|
+
// its opening tool_use fell inside an archived span while the (later,
|
|
320
|
+
// preferred) real result landed outside it — would be an orphaned block
|
|
321
|
+
// that breaks provider validation; skip it instead of emitting it.
|
|
322
|
+
const emittedToolUseIds = new Set();
|
|
225
323
|
for (const event of this.events) {
|
|
324
|
+
// Span bookkeeping runs on message events only: exit before entry so
|
|
325
|
+
// adjacent spans (A.to === B.from) hand over on the boundary message.
|
|
326
|
+
if (event.type === "message") {
|
|
327
|
+
const clientMessageId = typeof event.data.clientMessageId === "string" ? event.data.clientMessageId : undefined;
|
|
328
|
+
if (activeSpan && clientMessageId === activeSpan.toClientMessageId) {
|
|
329
|
+
activeSpan = null;
|
|
330
|
+
}
|
|
331
|
+
if (!activeSpan && clientMessageId && spansByFromId.has(clientMessageId)) {
|
|
332
|
+
activeSpan = spansByFromId.get(clientMessageId);
|
|
333
|
+
// One-shot: a duplicate `from` message (e.g. from a torn JSONL
|
|
334
|
+
// reload) must not reopen this span a second time — it would have
|
|
335
|
+
// no more `to` ahead of it and swallow the rest of the transcript.
|
|
336
|
+
spansByFromId.delete(clientMessageId);
|
|
337
|
+
messages.push({ role: "user", content: activeSpan.summary });
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
if (activeSpan)
|
|
341
|
+
continue; // archived span: drop every context event inside
|
|
226
342
|
switch (event.type) {
|
|
227
343
|
case "message": {
|
|
228
|
-
const { role, content } = event.data;
|
|
344
|
+
const { role, content, clientMessageId } = event.data;
|
|
345
|
+
// Record the live index of this message's FIRST emission before
|
|
346
|
+
// pushing it (the index it is about to occupy). Dropped-in-span
|
|
347
|
+
// messages never reach this point, so they get no entry.
|
|
348
|
+
if (typeof clientMessageId === "string" &&
|
|
349
|
+
!liveIndexByClientMessageId.has(clientMessageId)) {
|
|
350
|
+
liveIndexByClientMessageId.set(clientMessageId, messages.length);
|
|
351
|
+
}
|
|
352
|
+
if (role === "assistant" && Array.isArray(content)) {
|
|
353
|
+
for (const block of content) {
|
|
354
|
+
if (block.type === "tool_use" && typeof block.id === "string") {
|
|
355
|
+
emittedToolUseIds.add(block.id);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
229
359
|
messages.push({ role: role, content });
|
|
230
360
|
break;
|
|
231
361
|
}
|
|
@@ -237,7 +367,8 @@ export class Transcript {
|
|
|
237
367
|
case "tool_result": {
|
|
238
368
|
const eventToolCallId = event.data.toolCallId;
|
|
239
369
|
if (typeof eventToolCallId !== "string" ||
|
|
240
|
-
selectedToolResults.get(eventToolCallId) !== event
|
|
370
|
+
selectedToolResults.get(eventToolCallId) !== event ||
|
|
371
|
+
!emittedToolUseIds.has(eventToolCallId)) {
|
|
241
372
|
break;
|
|
242
373
|
}
|
|
243
374
|
const { toolCallId, result, error, contentBlocks } = event.data;
|
|
@@ -280,10 +411,12 @@ export class Transcript {
|
|
|
280
411
|
break;
|
|
281
412
|
}
|
|
282
413
|
// turn_boundary, run_result, session_meta, file_history, plan_operation, error
|
|
283
|
-
// are not included in LLM messages
|
|
414
|
+
// are not included in LLM messages. range_archive is handled entirely
|
|
415
|
+
// by the pre-pass above (it emits the summary at span entry and drops
|
|
416
|
+
// events inside the span); it never falls through to this switch.
|
|
284
417
|
}
|
|
285
418
|
}
|
|
286
|
-
return messages;
|
|
419
|
+
return { messages, liveIndexByClientMessageId };
|
|
287
420
|
}
|
|
288
421
|
getEvents(type) {
|
|
289
422
|
if (!type)
|
|
@@ -448,6 +581,18 @@ export class Transcript {
|
|
|
448
581
|
});
|
|
449
582
|
break;
|
|
450
583
|
}
|
|
584
|
+
case "range_archive": {
|
|
585
|
+
// A hand-picked context range is an explicit user selection: inject
|
|
586
|
+
// the archive summary as context but do NOT replace/drop the
|
|
587
|
+
// messages inside its span the way toMessages() does — the caller
|
|
588
|
+
// asked for exactly this range and expects to see it in full.
|
|
589
|
+
const { summary } = event.data;
|
|
590
|
+
messages.push({
|
|
591
|
+
role: "user",
|
|
592
|
+
content: `<system-reminder>Archived summary for part of this range:\n${summary}</system-reminder>`,
|
|
593
|
+
});
|
|
594
|
+
break;
|
|
595
|
+
}
|
|
451
596
|
}
|
|
452
597
|
}
|
|
453
598
|
return messages;
|
|
@@ -310,7 +310,10 @@ const BUILTIN_CONTRIBUTIONS = [
|
|
|
310
310
|
timeoutMs: 1_800_000, // 30min — sub-agent runs may execute many tool calls
|
|
311
311
|
},
|
|
312
312
|
execute: agentTool,
|
|
313
|
-
exposure: expose(HARNESS_TAGS, {
|
|
313
|
+
exposure: expose(HARNESS_TAGS, {
|
|
314
|
+
defaultPermissionRules: allow(agentToolDef.name),
|
|
315
|
+
availability: (ctx) => ctx.behaviorProfile !== "quickChatRestricted",
|
|
316
|
+
}),
|
|
314
317
|
},
|
|
315
318
|
{
|
|
316
319
|
definition: {
|
|
@@ -449,8 +449,6 @@ function isRegisteredSkillResourceRead(resolved) {
|
|
|
449
449
|
return false;
|
|
450
450
|
if (isSkillTreeResource(resolved, join(codeShellRoot, "skills")))
|
|
451
451
|
return true;
|
|
452
|
-
if (isInstalledPanelAppSkillResourceRead(resolved, codeShellRoot))
|
|
453
|
-
return true;
|
|
454
452
|
let cacheRoot;
|
|
455
453
|
try {
|
|
456
454
|
cacheRoot = realpathSync(join(codeShellRoot, "plugins", "cache"));
|
|
@@ -479,20 +477,26 @@ function isRegisteredSkillResourceRead(resolved) {
|
|
|
479
477
|
return false;
|
|
480
478
|
}
|
|
481
479
|
/**
|
|
482
|
-
* Panel
|
|
483
|
-
* `~/.code-shell/panel-apps` tree.
|
|
484
|
-
*
|
|
485
|
-
*
|
|
486
|
-
*
|
|
480
|
+
* Installed Panel Apps live under the otherwise-sensitive
|
|
481
|
+
* `~/.code-shell/panel-apps` tree. Their package contents are reviewed and
|
|
482
|
+
* copied by the installer, so ordinary source, manifests, assets and declared
|
|
483
|
+
* Agent resources should not require a second approval merely because their
|
|
484
|
+
* parent directory is `~/.code-shell`.
|
|
487
485
|
*
|
|
488
|
-
*
|
|
489
|
-
*
|
|
490
|
-
*
|
|
486
|
+
* Keep the exception read-only at the call site and require a valid installed
|
|
487
|
+
* registry entry plus a matching V2 manifest. Registry, app root, manifest and
|
|
488
|
+
* target are all realpathed and containment-checked. Credential-shaped files
|
|
489
|
+
* are still caught by `SENSITIVE_FILE_PATTERNS` before this exception, and a
|
|
490
|
+
* symlink escaping the installed package never inherits its read authority.
|
|
491
491
|
*/
|
|
492
|
-
function
|
|
492
|
+
function isInstalledPanelAppResourceRead(resolved) {
|
|
493
|
+
let codeShellRoot;
|
|
493
494
|
let appsRoot;
|
|
494
495
|
let registryPath;
|
|
495
496
|
try {
|
|
497
|
+
codeShellRoot = realpathSync(join(configuredUserHome(), ".code-shell"));
|
|
498
|
+
if (!isInsideDir(resolved, codeShellRoot))
|
|
499
|
+
return false;
|
|
496
500
|
appsRoot = realpathSync(join(codeShellRoot, "panel-apps"));
|
|
497
501
|
if (!isInsideDir(appsRoot, codeShellRoot))
|
|
498
502
|
return false;
|
|
@@ -526,29 +530,10 @@ function isInstalledPanelAppSkillResourceRead(resolved, codeShellRoot) {
|
|
|
526
530
|
if (!isInsideDir(manifestPath, appRoot) || !statSync(manifestPath).isFile())
|
|
527
531
|
continue;
|
|
528
532
|
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
529
|
-
if (manifest.schemaVersion !== 2 ||
|
|
530
|
-
manifest.id !== id ||
|
|
531
|
-
!Array.isArray(manifest.agent?.skills)) {
|
|
533
|
+
if (manifest.schemaVersion !== 2 || manifest.id !== id)
|
|
532
534
|
continue;
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
if (typeof skillEntry !== "string")
|
|
536
|
-
continue;
|
|
537
|
-
const segments = skillEntry.split("/");
|
|
538
|
-
if (segments.length !== 4 ||
|
|
539
|
-
segments[0] !== "agent" ||
|
|
540
|
-
segments[1] !== "skills" ||
|
|
541
|
-
!/^[a-z][a-z0-9-]{0,63}$/.test(segments[2] ?? "") ||
|
|
542
|
-
segments[3] !== "SKILL.md") {
|
|
543
|
-
continue;
|
|
544
|
-
}
|
|
545
|
-
const skillManifest = realpathSync(join(appRoot, ...segments));
|
|
546
|
-
if (!isInsideDir(skillManifest, appRoot) || !statSync(skillManifest).isFile())
|
|
547
|
-
continue;
|
|
548
|
-
const skillRoot = dirname(skillManifest);
|
|
549
|
-
if (isInsideDir(resolved, skillRoot))
|
|
550
|
-
return true;
|
|
551
|
-
}
|
|
535
|
+
if (isInsideDir(resolved, appRoot))
|
|
536
|
+
return true;
|
|
552
537
|
}
|
|
553
538
|
catch {
|
|
554
539
|
// A stale, malformed, or tampered Panel App entry grants no read access.
|
|
@@ -652,6 +637,13 @@ export function classifyPath(rawPath, opts) {
|
|
|
652
637
|
resolvedPath: resolved,
|
|
653
638
|
};
|
|
654
639
|
}
|
|
640
|
+
if (opts.operation === "read" && !sensitiveFile && isInstalledPanelAppResourceRead(resolved)) {
|
|
641
|
+
return {
|
|
642
|
+
decision: "allow",
|
|
643
|
+
reason: "installed Panel App resource read",
|
|
644
|
+
resolvedPath: resolved,
|
|
645
|
+
};
|
|
646
|
+
}
|
|
655
647
|
// Sensitive: write is always denied, read always asks. Workspace placement
|
|
656
648
|
// doesn't soften the rule — an `.env` in the project still asks on read.
|
|
657
649
|
if (sensitiveLabel) {
|
package/dist/types.d.ts
CHANGED
|
@@ -145,7 +145,7 @@ export interface RegisteredTool {
|
|
|
145
145
|
*/
|
|
146
146
|
timeoutMs?: number;
|
|
147
147
|
}
|
|
148
|
-
export type TranscriptEventType = "message" | "tool_use" | "tool_result" | "summary" | "context_transfer" | "content_replace" | "file_history" | "plan_operation" | "session_meta" | "subagent" | "external_file_changes" | "turn_boundary" | "run_result" | "goal_progress" | "turn_stopped" | "error";
|
|
148
|
+
export type TranscriptEventType = "message" | "tool_use" | "tool_result" | "summary" | "context_transfer" | "range_archive" | "content_replace" | "file_history" | "plan_operation" | "session_meta" | "subagent" | "external_file_changes" | "turn_boundary" | "run_result" | "goal_progress" | "turn_stopped" | "error";
|
|
149
149
|
export interface TranscriptEvent {
|
|
150
150
|
id: string;
|
|
151
151
|
type: TranscriptEventType;
|
package/package.json
CHANGED