@cjhyy/code-shell-core 0.8.9 → 0.8.11

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.
Files changed (76) hide show
  1. package/dist/automation/scheduler.js +49 -0
  2. package/dist/automation/store.d.ts +1 -1
  3. package/dist/automation/store.js +184 -10
  4. package/dist/cli/agent-server-stdio.js +7 -0
  5. package/dist/credentials/store.d.ts +14 -0
  6. package/dist/credentials/store.js +245 -42
  7. package/dist/engine/engine.js +45 -6
  8. package/dist/engine/file-history-hook.js +24 -5
  9. package/dist/engine/run-types.d.ts +9 -0
  10. package/dist/engine/turn-loop.js +9 -8
  11. package/dist/goal/lifecycle.d.ts +2 -0
  12. package/dist/goal/lifecycle.js +56 -33
  13. package/dist/index.d.ts +2 -3
  14. package/dist/index.internal.d.ts +1 -0
  15. package/dist/index.internal.js +1 -0
  16. package/dist/index.js +2 -2
  17. package/dist/links/cli.d.ts +2 -0
  18. package/dist/links/cli.js +11 -4
  19. package/dist/model-catalog/index.js +19 -4
  20. package/dist/model-catalog/save-entry.js +122 -61
  21. package/dist/model-catalog/types.js +27 -23
  22. package/dist/panel-apps/installer.js +27 -14
  23. package/dist/panel-apps/registry.js +60 -12
  24. package/dist/plugins/installedPlugins.d.ts +4 -0
  25. package/dist/plugins/installedPlugins.js +70 -30
  26. package/dist/plugins/installer/types.d.ts +12 -12
  27. package/dist/plugins/installer/update.js +37 -38
  28. package/dist/plugins/knownMarketplaces.d.ts +7 -3
  29. package/dist/plugins/knownMarketplaces.js +127 -23
  30. package/dist/plugins/pluginCatalog.js +18 -4
  31. package/dist/plugins/pluginHookApproval.js +56 -60
  32. package/dist/plugins/pluginMcpApproval.js +50 -52
  33. package/dist/profile/catalog-store.js +39 -4
  34. package/dist/profile/catalog.js +55 -15
  35. package/dist/profile/store.js +51 -21
  36. package/dist/protocol/chat-session-manager.d.ts +9 -0
  37. package/dist/protocol/chat-session-manager.js +13 -0
  38. package/dist/protocol/chat-session.d.ts +5 -0
  39. package/dist/protocol/chat-session.js +1 -0
  40. package/dist/protocol/server.d.ts +2 -0
  41. package/dist/protocol/server.js +75 -29
  42. package/dist/protocol/types.d.ts +8 -0
  43. package/dist/run/FileRunStore.d.ts +2 -0
  44. package/dist/run/FileRunStore.js +153 -18
  45. package/dist/run/Heartbeat.js +63 -4
  46. package/dist/services/auto-dream.js +39 -17
  47. package/dist/services/session-memory.js +107 -8
  48. package/dist/session/file-history.d.ts +63 -2
  49. package/dist/session/file-history.js +593 -86
  50. package/dist/session/session-manager.d.ts +1 -0
  51. package/dist/session/session-manager.js +52 -21
  52. package/dist/session/transcript.js +33 -3
  53. package/dist/session/undo-target.d.ts +15 -6
  54. package/dist/session/undo-target.js +26 -9
  55. package/dist/settings/manager.d.ts +22 -3
  56. package/dist/settings/manager.js +185 -50
  57. package/dist/settings/schema.d.ts +3 -3
  58. package/dist/sources/adapters/local-files.js +49 -4
  59. package/dist/sources/catalog.js +64 -18
  60. package/dist/sources/types.d.ts +3 -3
  61. package/dist/sources/types.js +7 -4
  62. package/dist/themes/installer.js +192 -28
  63. package/dist/tool-system/builtin/add-marketplace.js +21 -1
  64. package/dist/tool-system/builtin/cron.d.ts +2 -1
  65. package/dist/tool-system/builtin/cron.js +20 -6
  66. package/dist/tool-system/builtin/index.js +44 -0
  67. package/dist/tool-system/builtin/install-capability.d.ts +52 -0
  68. package/dist/tool-system/builtin/install-capability.js +1057 -0
  69. package/dist/tool-system/builtin/skill.js +3 -1
  70. package/dist/tool-system/executor.js +1 -0
  71. package/dist/tool-system/registry.js +5 -0
  72. package/dist/tool-system/sandbox/index.d.ts +1 -0
  73. package/dist/tool-system/sandbox/index.js +4 -1
  74. package/dist/utils/file-mutex.d.ts +2 -0
  75. package/dist/utils/file-mutex.js +29 -4
  76. package/package.json +2 -1
@@ -8,7 +8,7 @@
8
8
  * ~/.code-shell/human-repos.json —— 注册表(owner/repo 列表)
9
9
  * ~/.code-shell/human-repos/<key>/ —— 各仓库的浅克隆
10
10
  */
11
- import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
11
+ import { closeSync, constants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
12
12
  import { randomUUID } from "node:crypto";
13
13
  import { basename, dirname, join } from "node:path";
14
14
  import { codeShellHome } from "../session/session-manager.js";
@@ -19,6 +19,7 @@ import { acquireLockOnPath, mutateJsonFile } from "../utils/file-mutex.js";
19
19
  import { gitClone, githubRepoToCloneUrl } from "../plugins/gitOps.js";
20
20
  import { CATALOG_REPO_RE, readCatalogFromDir, sourceToRepoKey, } from "./catalog.js";
21
21
  const MAX_REPOS = 32;
22
+ const MAX_REPO_REGISTRY_BYTES = 256 * 1024;
22
23
  export function humanReposRoot() {
23
24
  return join(codeShellHome(), "human-repos");
24
25
  }
@@ -29,13 +30,32 @@ export function humanRepoDir(repo) {
29
30
  return join(humanReposRoot(), sourceToRepoKey(repo));
30
31
  }
31
32
  export function listHumanRepos() {
33
+ let descriptor;
32
34
  try {
33
35
  // Missing or corrupt registry reads as "no repos" — never fatal.
34
- return parseRegistry(readFileSync(registryPath(), "utf-8"));
36
+ const path = registryPath();
37
+ const metadata = lstatSync(path);
38
+ if (metadata.isSymbolicLink() ||
39
+ !metadata.isFile() ||
40
+ metadata.size > MAX_REPO_REGISTRY_BYTES) {
41
+ throw new Error("invalid digital-human repo registry");
42
+ }
43
+ descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
44
+ const opened = fstatSync(descriptor);
45
+ if (!opened.isFile() || opened.size > MAX_REPO_REGISTRY_BYTES) {
46
+ throw new Error("invalid digital-human repo registry");
47
+ }
48
+ return parseRegistry(readFileSync(descriptor, "utf-8"));
35
49
  }
36
- catch {
50
+ catch (error) {
51
+ if (error.code === "ENOENT")
52
+ return [];
37
53
  return [];
38
54
  }
55
+ finally {
56
+ if (descriptor !== undefined)
57
+ closeSync(descriptor);
58
+ }
39
59
  }
40
60
  function parseRegistry(raw) {
41
61
  if (raw === undefined)
@@ -44,12 +64,26 @@ function parseRegistry(raw) {
44
64
  const parsed = JSON.parse(raw);
45
65
  if (!Array.isArray(parsed.repos))
46
66
  return [];
67
+ const seen = new Set();
47
68
  return parsed.repos
69
+ .slice(0, MAX_REPOS)
48
70
  .filter((r) => !!r &&
49
71
  typeof r === "object" &&
50
72
  typeof r.repo === "string" &&
51
73
  CATALOG_REPO_RE.test(r.repo))
52
- .map((r) => ({ repo: r.repo, addedAt: Number(r.addedAt) || 0 }));
74
+ .filter((r) => {
75
+ const normalized = r.repo.toLowerCase();
76
+ if (seen.has(normalized))
77
+ return false;
78
+ seen.add(normalized);
79
+ return true;
80
+ })
81
+ .map((r) => ({
82
+ repo: r.repo,
83
+ addedAt: typeof r.addedAt === "number" && Number.isSafeInteger(r.addedAt) && r.addedAt >= 0
84
+ ? r.addedAt
85
+ : 0,
86
+ }));
53
87
  }
54
88
  catch {
55
89
  return [];
@@ -101,6 +135,7 @@ function updateRegistry(change) {
101
135
  serialize: (repos) => `${JSON.stringify({ repos }, null, 2)}\n`,
102
136
  mutation: (current) => ({ value: change(current) }),
103
137
  mode: 0o600,
138
+ maxBytes: MAX_REPO_REGISTRY_BYTES,
104
139
  });
105
140
  }
106
141
  /**
@@ -9,13 +9,18 @@
9
9
  *
10
10
  * 本模块只做**读取与校验**,克隆/更新由 host 驱动(涉及网络与磁盘写入)。
11
11
  */
12
- import { existsSync, readFileSync } from "node:fs";
12
+ import { closeSync, constants, existsSync, fstatSync, lstatSync, openSync, readFileSync, } from "node:fs";
13
13
  import { join } from "node:path";
14
14
  import { WorkspaceProfileSchema } from "./types.js";
15
15
  /** `owner/repo`,与 SKILL_REPO_RE 同源,供仓库地址复用。 */
16
16
  export const CATALOG_REPO_RE = /^[A-Za-z0-9][A-Za-z0-9-]{0,38}\/[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
17
17
  /** 单个目录段的安全名,挡住 `..`、分隔符与 NUL。 */
18
18
  const SAFE_SEGMENT_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
19
+ const MAX_MANIFEST_BYTES = 1024 * 1024;
20
+ const MAX_PROFILE_BYTES = 256 * 1024;
21
+ const MAX_MANIFEST_HUMANS = 1_000;
22
+ const MAX_MANIFEST_TEAMS = 500;
23
+ const MAX_ENTRY_TAGS = 64;
19
24
  /**
20
25
  * `owner/repo` → 一个安全的目录段。小写化保证同一仓库不同大小写写法命中同一份
21
26
  * 缓存;斜杠换成 `-` 后仍需通过 SAFE_SEGMENT_RE,杜绝路径逃逸。
@@ -37,44 +42,63 @@ export function sourceToRepoKey(repo) {
37
42
  export function parseHumansManifest(raw) {
38
43
  const value = JSON.parse(raw);
39
44
  const rawHumans = Array.isArray(value.humans) ? value.humans : [];
45
+ if (rawHumans.length > MAX_MANIFEST_HUMANS)
46
+ throw new Error("too many humans in manifest");
40
47
  const humans = [];
41
48
  for (const item of rawHumans) {
42
49
  if (!item || typeof item !== "object" || Array.isArray(item))
43
50
  continue;
44
51
  const entry = item;
45
- if (typeof entry.name !== "string" || !entry.name)
52
+ if (typeof entry.name !== "string" || !entry.name || entry.name.length > 64)
46
53
  continue;
47
54
  humans.push({
48
55
  name: entry.name,
49
- ...(typeof entry.label === "string" ? { label: entry.label } : {}),
50
- ...(typeof entry.description === "string" ? { description: entry.description } : {}),
51
- ...(typeof entry.category === "string" ? { category: entry.category } : {}),
56
+ ...(typeof entry.label === "string" && entry.label.length <= 120
57
+ ? { label: entry.label }
58
+ : {}),
59
+ ...(typeof entry.description === "string" && entry.description.length <= 4_096
60
+ ? { description: entry.description }
61
+ : {}),
62
+ ...(typeof entry.category === "string" && entry.category.length <= 120
63
+ ? { category: entry.category }
64
+ : {}),
52
65
  tags: Array.isArray(entry.tags)
53
- ? entry.tags.filter((t) => typeof t === "string")
66
+ ? entry.tags
67
+ .slice(0, MAX_ENTRY_TAGS)
68
+ .filter((t) => typeof t === "string" && t.length <= 120)
54
69
  : [],
55
70
  });
56
71
  }
57
72
  const rawTeams = Array.isArray(value.teams) ? value.teams : [];
73
+ if (rawTeams.length > MAX_MANIFEST_TEAMS)
74
+ throw new Error("too many teams in manifest");
58
75
  const teams = [];
59
76
  for (const item of rawTeams) {
60
77
  if (!item || typeof item !== "object" || Array.isArray(item))
61
78
  continue;
62
79
  const entry = item;
63
- if (typeof entry.id !== "string" || !entry.id)
80
+ if (typeof entry.id !== "string" || !entry.id || entry.id.length > 64)
64
81
  continue;
65
- if (typeof entry.name !== "string" || !entry.name)
82
+ if (typeof entry.name !== "string" || !entry.name || entry.name.length > 120)
66
83
  continue;
67
84
  const rawMembers = Array.isArray(entry.members) ? entry.members : [];
68
- const members = rawMembers.filter((m) => typeof m === "string" && m.length > 0);
69
- if (members.length < 2)
85
+ const members = rawMembers.filter((m) => typeof m === "string" && SAFE_SEGMENT_RE.test(m));
86
+ if (members.length < 2 || members.length > 8 || new Set(members).size !== members.length) {
70
87
  continue;
88
+ }
71
89
  teams.push({
72
90
  id: entry.id,
73
91
  name: entry.name,
74
- ...(typeof entry.description === "string" ? { description: entry.description } : {}),
92
+ ...(typeof entry.description === "string" && entry.description.length <= 1_000
93
+ ? { description: entry.description }
94
+ : {}),
75
95
  members,
76
- ...(typeof entry.lead === "string" && entry.lead ? { lead: entry.lead } : {}),
77
- ...(typeof entry.playbook === "string" && entry.playbook ? { playbook: entry.playbook } : {}),
96
+ ...(typeof entry.lead === "string" && SAFE_SEGMENT_RE.test(entry.lead)
97
+ ? { lead: entry.lead }
98
+ : {}),
99
+ ...(typeof entry.playbook === "string" && entry.playbook.length <= 4_000
100
+ ? { playbook: entry.playbook }
101
+ : {}),
78
102
  });
79
103
  }
80
104
  return {
@@ -84,6 +108,22 @@ export function parseHumansManifest(raw) {
84
108
  teams,
85
109
  };
86
110
  }
111
+ function readBoundedRegularFile(path, maxBytes) {
112
+ const pathInfo = lstatSync(path);
113
+ if (pathInfo.isSymbolicLink() || !pathInfo.isFile() || pathInfo.size > maxBytes) {
114
+ throw new Error("not a bounded regular file");
115
+ }
116
+ const descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
117
+ try {
118
+ const opened = fstatSync(descriptor);
119
+ if (!opened.isFile() || opened.size > maxBytes)
120
+ throw new Error("not a bounded regular file");
121
+ return readFileSync(descriptor, "utf8");
122
+ }
123
+ finally {
124
+ closeSync(descriptor);
125
+ }
126
+ }
87
127
  /** 读取一个已克隆的数字人仓库目录。 */
88
128
  export function readCatalogFromDir(dir, sourceRepo) {
89
129
  const manifestPath = join(dir, "humans.json");
@@ -92,7 +132,7 @@ export function readCatalogFromDir(dir, sourceRepo) {
92
132
  }
93
133
  let manifest;
94
134
  try {
95
- manifest = parseHumansManifest(readFileSync(manifestPath, "utf-8"));
135
+ manifest = parseHumansManifest(readBoundedRegularFile(manifestPath, MAX_MANIFEST_BYTES));
96
136
  }
97
137
  catch (error) {
98
138
  return {
@@ -112,7 +152,7 @@ export function readCatalogFromDir(dir, sourceRepo) {
112
152
  }
113
153
  const profilePath = join(dir, "humans", item.name, "profile.json");
114
154
  try {
115
- const profile = WorkspaceProfileSchema.parse(JSON.parse(readFileSync(profilePath, "utf-8")));
155
+ const profile = WorkspaceProfileSchema.parse(JSON.parse(readBoundedRegularFile(profilePath, MAX_PROFILE_BYTES)));
116
156
  entries.push({
117
157
  profile,
118
158
  sourceRepo,
@@ -4,11 +4,13 @@
4
4
  * core 不内置任何领域 profile;样例见 docs/examples/workspace-profile-sample.md。
5
5
  */
6
6
  import { randomUUID } from "node:crypto";
7
- import { lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync, } from "node:fs";
7
+ import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, opendirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync, } from "node:fs";
8
8
  import { isAbsolute, join, relative, sep } from "node:path";
9
9
  import { logger } from "../logging/logger.js";
10
10
  import { codeShellHome } from "../session/session-manager.js";
11
11
  import { WORKSPACE_PROFILE_NAME_RE, WorkspaceProfileSchema, } from "./types.js";
12
+ const MAX_PROFILE_FILE_BYTES = 256 * 1024;
13
+ const MAX_PROFILE_LIBRARY_ENTRIES = 10_000;
12
14
  export function workspaceProfilesRoot() {
13
15
  return join(codeShellHome(), "profiles");
14
16
  }
@@ -72,7 +74,7 @@ function checkedProfileFile(name) {
72
74
  const info = lstatIfPresent(path);
73
75
  if (!info)
74
76
  return undefined;
75
- if (info.isSymbolicLink() || !info.isFile()) {
77
+ if (info.isSymbolicLink() || !info.isFile() || info.size > MAX_PROFILE_FILE_BYTES) {
76
78
  throw new Error(`Invalid workspace profile file: ${path}`);
77
79
  }
78
80
  return path;
@@ -81,45 +83,69 @@ export function readWorkspaceProfile(name) {
81
83
  if (!WORKSPACE_PROFILE_NAME_RE.test(name))
82
84
  return undefined;
83
85
  const path = join(workspaceProfileDir(name), "profile.json");
86
+ let descriptor;
84
87
  try {
85
88
  const checkedPath = checkedProfileFile(name);
86
89
  if (!checkedPath)
87
90
  return undefined;
88
- return WorkspaceProfileSchema.parse(JSON.parse(readFileSync(checkedPath, "utf-8")));
91
+ descriptor = openSync(checkedPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
92
+ const opened = fstatSync(descriptor);
93
+ if (!opened.isFile() || opened.size > MAX_PROFILE_FILE_BYTES) {
94
+ throw new Error("workspace profile file is too large");
95
+ }
96
+ return WorkspaceProfileSchema.parse(JSON.parse(readFileSync(descriptor, "utf-8")));
89
97
  }
90
98
  catch (error) {
91
99
  throw new Error(`Invalid workspace profile "${name}" at ${path}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
92
100
  }
101
+ finally {
102
+ if (descriptor !== undefined)
103
+ closeSync(descriptor);
104
+ }
93
105
  }
94
106
  export function listWorkspaceProfiles() {
95
107
  const root = checkedProfilesRoot();
96
108
  if (!root)
97
109
  return [];
98
110
  const out = [];
99
- for (const entry of readdirSync(root, { withFileTypes: true })) {
100
- if (!entry.isDirectory()) {
101
- if (entry.isSymbolicLink()) {
111
+ const directory = opendirSync(root);
112
+ let seen = 0;
113
+ try {
114
+ for (;;) {
115
+ const entry = directory.readSync();
116
+ if (!entry)
117
+ break;
118
+ seen += 1;
119
+ if (seen > MAX_PROFILE_LIBRARY_ENTRIES) {
120
+ throw new Error("Workspace profile library contains too many entries");
121
+ }
122
+ if (!entry.isDirectory()) {
123
+ if (entry.isSymbolicLink()) {
124
+ logger.warn("profile.library_entry_invalid", {
125
+ cat: "profile",
126
+ name: entry.name,
127
+ error: "symbolic links are not allowed",
128
+ });
129
+ }
130
+ continue;
131
+ }
132
+ try {
133
+ const profile = readWorkspaceProfile(entry.name);
134
+ if (profile)
135
+ out.push(profile);
136
+ }
137
+ catch (error) {
102
138
  logger.warn("profile.library_entry_invalid", {
103
139
  cat: "profile",
104
140
  name: entry.name,
105
- error: "symbolic links are not allowed",
141
+ error: error instanceof Error ? error.message : String(error),
106
142
  });
107
143
  }
108
- continue;
109
- }
110
- try {
111
- const profile = readWorkspaceProfile(entry.name);
112
- if (profile)
113
- out.push(profile);
114
- }
115
- catch (error) {
116
- logger.warn("profile.library_entry_invalid", {
117
- cat: "profile",
118
- name: entry.name,
119
- error: error instanceof Error ? error.message : String(error),
120
- });
121
144
  }
122
145
  }
146
+ finally {
147
+ directory.closeSync();
148
+ }
123
149
  return out.sort((a, b) => a.name.localeCompare(b.name));
124
150
  }
125
151
  /**
@@ -144,7 +170,11 @@ export function saveWorkspaceProfile(profile) {
144
170
  }
145
171
  const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
146
172
  try {
147
- writeFileSync(tmp, `${JSON.stringify(parsed, null, 2)}\n`, {
173
+ const serialized = `${JSON.stringify(parsed, null, 2)}\n`;
174
+ if (Buffer.byteLength(serialized, "utf8") > MAX_PROFILE_FILE_BYTES) {
175
+ throw new Error("workspace profile file is too large");
176
+ }
177
+ writeFileSync(tmp, serialized, {
148
178
  encoding: "utf-8",
149
179
  mode: 0o600,
150
180
  flag: "wx",
@@ -95,6 +95,15 @@ export declare class ChatSessionManager {
95
95
  * the values so a callback that closes a session can't perturb the walk.
96
96
  */
97
97
  forEachSession(fn: (s: ChatSession) => void): void;
98
+ /** First live Engine for legacy fallbacks that genuinely accept any owner. */
99
+ getAnyLiveEngine(): Engine | undefined;
100
+ /**
101
+ * Build a detached Engine for global model/provider/settings queries. Keeping
102
+ * this seam on the manager avoids protocol code reaching into its private
103
+ * factory and, importantly, avoids borrowing project-local state from an
104
+ * arbitrary live session.
105
+ */
106
+ createDetachedEngine(slice?: Partial<EngineConfigSlice>): Engine;
98
107
  /** Snapshot-safe, read-only source shared by query("sessions") and Pet projection. */
99
108
  getLiveSessionSnapshot(): LiveChatSessionSnapshot;
100
109
  close(sessionId: string): Promise<void>;
@@ -164,6 +164,19 @@ export class ChatSessionManager {
164
164
  for (const s of [...this.sessions.values()])
165
165
  fn(s);
166
166
  }
167
+ /** First live Engine for legacy fallbacks that genuinely accept any owner. */
168
+ getAnyLiveEngine() {
169
+ return this.sessions.values().next().value?.engine;
170
+ }
171
+ /**
172
+ * Build a detached Engine for global model/provider/settings queries. Keeping
173
+ * this seam on the manager avoids protocol code reaching into its private
174
+ * factory and, importantly, avoids borrowing project-local state from an
175
+ * arbitrary live session.
176
+ */
177
+ createDetachedEngine(slice = {}) {
178
+ return this.factory(slice);
179
+ }
167
180
  /** Snapshot-safe, read-only source shared by query("sessions") and Pet projection. */
168
181
  getLiveSessionSnapshot() {
169
182
  const sessions = [];
@@ -33,6 +33,11 @@ export interface TurnOpts {
33
33
  injected?: boolean;
34
34
  /** Stable id for this user-intent; forwarded to Engine.run for idempotency. */
35
35
  clientMessageId?: string;
36
+ /** Archive history before this turn at the engine's pre-model safe point. */
37
+ archiveBeforeCurrentTurn?: {
38
+ fromClientMessageId?: string;
39
+ segmentId?: string;
40
+ };
36
41
  /** Structured input attachments for this turn. */
37
42
  attachments?: InputAttachmentMeta[];
38
43
  /** Permission mode snapshot for this queued turn only. */
@@ -290,6 +290,7 @@ export class ChatSession {
290
290
  goal: next.opts.goal,
291
291
  injected: next.opts.injected,
292
292
  clientMessageId: next.opts.clientMessageId,
293
+ archiveBeforeCurrentTurn: next.opts.archiveBeforeCurrentTurn,
293
294
  attachments: next.opts.attachments,
294
295
  permissionMode: next.opts.permissionMode,
295
296
  planMode: next.opts.planMode,
@@ -375,6 +375,8 @@ export declare class AgentServer {
375
375
  * selector) can query models/providers without requiring a chat session first.
376
376
  */
377
377
  private anyEngine;
378
+ /** Neutral engine for truly global queries; never inherits a live project's cwd or policy. */
379
+ private detachedQueryEngine;
378
380
  close(): void;
379
381
  /** Release only this transport's approval ownership. Safe on socket close
380
382
  * even when the ChatSessionManager is shared by other TCP connections. */
@@ -62,6 +62,27 @@ function runInputError(params) {
62
62
  (typeof params.quickChatClaimId !== "string" || params.quickChatClaimId.length === 0)) {
63
63
  return "quickChatClaimId must be a non-empty string";
64
64
  }
65
+ if (params.archiveBeforeCurrentTurn !== undefined) {
66
+ const archive = params.archiveBeforeCurrentTurn;
67
+ if (!archive || typeof archive !== "object" || Array.isArray(archive)) {
68
+ return "archiveBeforeCurrentTurn must be an object";
69
+ }
70
+ if (typeof params.clientMessageId !== "string" || params.clientMessageId.length === 0) {
71
+ return "archiveBeforeCurrentTurn requires clientMessageId";
72
+ }
73
+ for (const [field, value] of [
74
+ ["fromClientMessageId", archive.fromClientMessageId],
75
+ ["segmentId", archive.segmentId],
76
+ ]) {
77
+ if (value !== undefined &&
78
+ (typeof value !== "string" ||
79
+ value.length === 0 ||
80
+ value.length > 256 ||
81
+ /[\r\n]/.test(value))) {
82
+ return `archiveBeforeCurrentTurn.${field} must be a bounded non-empty string`;
83
+ }
84
+ }
85
+ }
65
86
  if (params.behaviorMode !== undefined &&
66
87
  (typeof params.behaviorMode !== "string" || params.behaviorMode.length === 0)) {
67
88
  return `invalid behavior mode: ${String(params.behaviorMode)}`;
@@ -104,6 +125,20 @@ function runInputError(params) {
104
125
  // extension-owned param shapes) lives in ExtensionModule.validateRunParams.
105
126
  return null;
106
127
  }
128
+ function goalExtensionInputError(params) {
129
+ const fields = ["addTurns", "addTokenBudget", "addTimeBudgetMs", "addStopBlocks"];
130
+ let supplied = false;
131
+ for (const field of fields) {
132
+ const value = params[field];
133
+ if (value === undefined)
134
+ continue;
135
+ supplied = true;
136
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
137
+ return `${field} must be a positive safe integer`;
138
+ }
139
+ }
140
+ return supplied ? null : "at least one goal extension is required";
141
+ }
107
142
  const COMPACT_STREAM_STRATEGIES = new Set([
108
143
  "micro",
109
144
  "summary",
@@ -1191,6 +1226,7 @@ export class AgentServer {
1191
1226
  this.notify(Methods.StreamEvent, { sessionId: sid, event });
1192
1227
  },
1193
1228
  clientMessageId: typeof params.clientMessageId === "string" ? params.clientMessageId : undefined,
1229
+ archiveBeforeCurrentTurn: params.archiveBeforeCurrentTurn,
1194
1230
  permissionMode: params.permissionMode,
1195
1231
  planMode: params.planMode,
1196
1232
  behaviorMode: params.behaviorMode,
@@ -1472,7 +1508,17 @@ export class AgentServer {
1472
1508
  * limits, or an error if no session / no active run.
1473
1509
  */
1474
1510
  handleGoalExtend(req) {
1475
- const params = (req.params ?? {});
1511
+ const rawParams = req.params;
1512
+ if (!rawParams || typeof rawParams !== "object" || Array.isArray(rawParams)) {
1513
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, "goal extension params are required"));
1514
+ return;
1515
+ }
1516
+ const inputError = goalExtensionInputError(rawParams);
1517
+ if (inputError) {
1518
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, inputError));
1519
+ return;
1520
+ }
1521
+ const params = rawParams;
1476
1522
  const ext = {
1477
1523
  addTurns: params.addTurns,
1478
1524
  addTokenBudget: params.addTokenBudget,
@@ -2136,7 +2182,7 @@ export class AgentServer {
2136
2182
  resolved = session.engine;
2137
2183
  }
2138
2184
  else {
2139
- resolved = this.anyEngine();
2185
+ resolved ??= this.anyEngine();
2140
2186
  }
2141
2187
  }
2142
2188
  if (!resolved) {
@@ -2147,16 +2193,16 @@ export class AgentServer {
2147
2193
  }
2148
2194
  async handleQuery(req) {
2149
2195
  const params = (req.params ?? {});
2150
- // For query operations we prefer the legacyEngine; if absent borrow any
2151
- // session engine from the manager (model pool, settings are shared).
2152
- const engine = this.legacyEngine ?? this.anyEngine();
2196
+ // Global queries use a detached manager-owned engine. Session-scoped cases
2197
+ // below resolve the exact owner from params.sessionId instead of borrowing
2198
+ // whichever live project's engine happens to be first in a Map.
2199
+ const engine = this.legacyEngine ?? this.detachedQueryEngine();
2153
2200
  switch (params.type) {
2154
2201
  case "tools": {
2155
- if (!engine) {
2156
- this.transport.send(createErrorResponse(req.id, ErrorCodes.InternalError, "No engine available for tools query"));
2202
+ const toolsEngine = await this.resolveEngineForSessionQuery(req, params.sessionId, engine, "tools");
2203
+ if (!toolsEngine)
2157
2204
  return;
2158
- }
2159
- const registry = engine.getToolRegistry();
2205
+ const registry = toolsEngine.getToolRegistry();
2160
2206
  const tools = typeof registry.listToolsDetailed === "function"
2161
2207
  ? registry
2162
2208
  .listToolsDetailed()
@@ -2183,16 +2229,15 @@ export class AgentServer {
2183
2229
  break;
2184
2230
  }
2185
2231
  case "config": {
2186
- if (!engine) {
2187
- this.transport.send(createErrorResponse(req.id, ErrorCodes.InternalError, "No engine available for config query"));
2232
+ const configEngine = await this.resolveEngineForSessionQuery(req, params.sessionId, engine, "config");
2233
+ if (!configEngine)
2188
2234
  return;
2189
- }
2190
- const config = engine.getConfig();
2235
+ const config = configEngine.getConfig();
2191
2236
  this.transport.send(createResponse(req.id, {
2192
2237
  type: "config",
2193
2238
  data: {
2194
2239
  permissionMode: config.permissionMode ?? "default",
2195
- planMode: engine.planMode ?? false,
2240
+ planMode: configEngine.planMode ?? false,
2196
2241
  preset: config.preset,
2197
2242
  model: config.llm.model,
2198
2243
  cwd: config.cwd,
@@ -2202,21 +2247,20 @@ export class AgentServer {
2202
2247
  llm: redactLlmConfig(config.llm),
2203
2248
  // Resolved feature flags (defaults merged with settings overlay)
2204
2249
  // so the /features command can list current state.
2205
- featureFlags: engine.getFeatureFlags(),
2250
+ featureFlags: configEngine.getFeatureFlags(),
2206
2251
  // Effective permission rules so /permissions can list them (TODO 5.1).
2207
- permissionRules: engine.getPermissionRules(),
2252
+ permissionRules: configEngine.getPermissionRules(),
2208
2253
  },
2209
2254
  }));
2210
2255
  break;
2211
2256
  }
2212
2257
  case "session_detail": {
2213
- if (!engine) {
2214
- this.transport.send(createErrorResponse(req.id, ErrorCodes.InternalError, "No engine available for session_detail query"));
2215
- return;
2216
- }
2217
2258
  if (params.sessionId) {
2259
+ const detailEngine = await this.resolveEngineForSessionQuery(req, params.sessionId, engine, "session_detail");
2260
+ if (!detailEngine)
2261
+ return;
2218
2262
  try {
2219
- const bundle = engine.getSessionManager().resume(params.sessionId);
2263
+ const bundle = detailEngine.getSessionManager().resume(params.sessionId);
2220
2264
  const data = {
2221
2265
  state: bundle.state,
2222
2266
  transcript: bundle.transcript.getEvents(),
@@ -3164,16 +3208,18 @@ export class AgentServer {
3164
3208
  anyEngine() {
3165
3209
  if (!this.chatManager)
3166
3210
  return null;
3167
- const sessions = this.chatManager.sessions;
3168
- const first = sessions.values().next().value;
3169
- if (first)
3170
- return first.engine;
3211
+ const live = this.chatManager.getAnyLiveEngine();
3212
+ if (live)
3213
+ return live;
3214
+ return this.detachedQueryEngine();
3215
+ }
3216
+ /** Neutral engine for truly global queries; never inherits a live project's cwd or policy. */
3217
+ detachedQueryEngine() {
3218
+ if (!this.chatManager)
3219
+ return null;
3171
3220
  if (this.globalQueryEngine)
3172
3221
  return this.globalQueryEngine;
3173
- const factory = this.chatManager.factory;
3174
- if (typeof factory !== "function")
3175
- return null;
3176
- this.globalQueryEngine = factory({});
3222
+ this.globalQueryEngine = this.chatManager.createDetachedEngine();
3177
3223
  return this.globalQueryEngine;
3178
3224
  }
3179
3225
  // ─── Lifecycle ──────────────────────────────────────────────────
@@ -80,6 +80,14 @@ export interface RunParams {
80
80
  attachments?: InputAttachmentMeta[];
81
81
  /** Stable id for the user's submit intent; duplicate ids are idempotent. */
82
82
  clientMessageId?: string;
83
+ /**
84
+ * Archive the preceding history after this message is durably appended and
85
+ * before its first model call. `clientMessageId` is the exclusive end anchor.
86
+ */
87
+ archiveBeforeCurrentTurn?: {
88
+ fromClientMessageId?: string;
89
+ segmentId?: string;
90
+ };
83
91
  /** Desktop host ownership generation for process-local Quick Chat runs. */
84
92
  quickChatClaimId?: string;
85
93
  /**
@@ -18,6 +18,8 @@ export declare class FileRunStore implements RunStore {
18
18
  private ensureRunDir;
19
19
  private writeJson;
20
20
  private readJson;
21
+ private assertRealDirectory;
22
+ private assertSafeFileTarget;
21
23
  /** Serializes concurrent JSONL appends per file path. */
22
24
  private readonly appendLocks;
23
25
  private appendJsonl;