sanook-cli 0.4.0 → 0.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.
Files changed (238) hide show
  1. package/.env.example +19 -0
  2. package/CHANGELOG.md +173 -0
  3. package/README.md +153 -20
  4. package/README.th.md +136 -0
  5. package/dist/agentContext.js +4 -0
  6. package/dist/approval.js +6 -0
  7. package/dist/bin.js +405 -57
  8. package/dist/brain.js +92 -59
  9. package/dist/brand.js +47 -0
  10. package/dist/checkpoint.js +37 -0
  11. package/dist/commands.js +86 -6
  12. package/dist/compaction.js +76 -5
  13. package/dist/config.js +100 -12
  14. package/dist/cost.js +60 -3
  15. package/dist/doctor.js +92 -0
  16. package/dist/gateway/auth.js +2 -2
  17. package/dist/gateway/ledger.js +2 -2
  18. package/dist/gateway/scheduler.js +1 -0
  19. package/dist/gateway/serve.js +6 -4
  20. package/dist/gateway/server.js +10 -2
  21. package/dist/git.js +11 -2
  22. package/dist/hooks.js +43 -17
  23. package/dist/knowledge.js +48 -49
  24. package/dist/loop.js +182 -66
  25. package/dist/lsp/client.js +173 -0
  26. package/dist/lsp/framing.js +56 -0
  27. package/dist/lsp/index.js +138 -0
  28. package/dist/lsp/servers.js +82 -0
  29. package/dist/mcp-server.js +244 -0
  30. package/dist/mcp.js +184 -29
  31. package/dist/memory-store.js +559 -0
  32. package/dist/memory.js +143 -29
  33. package/dist/orchestrate.js +150 -0
  34. package/dist/providers/codex.js +21 -7
  35. package/dist/providers/keys.js +3 -2
  36. package/dist/providers/models.js +22 -6
  37. package/dist/providers/registry.js +155 -1
  38. package/dist/repomap.js +93 -0
  39. package/dist/search/chunk.js +158 -0
  40. package/dist/search/embed-store.js +187 -0
  41. package/dist/search/engine.js +203 -0
  42. package/dist/search/fuse.js +35 -0
  43. package/dist/search/index-core.js +187 -0
  44. package/dist/search/indexer.js +241 -0
  45. package/dist/search/store.js +77 -0
  46. package/dist/session.js +42 -8
  47. package/dist/skill-install.js +10 -10
  48. package/dist/skills.js +12 -9
  49. package/dist/summarize.js +31 -0
  50. package/dist/tools/bash.js +21 -2
  51. package/dist/tools/diagnostics.js +41 -0
  52. package/dist/tools/edit.js +29 -7
  53. package/dist/tools/index.js +8 -1
  54. package/dist/tools/list.js +7 -2
  55. package/dist/tools/permission.js +90 -9
  56. package/dist/tools/read.js +23 -4
  57. package/dist/tools/remember.js +1 -1
  58. package/dist/tools/sandbox.js +61 -0
  59. package/dist/tools/search.js +105 -4
  60. package/dist/tools/task.js +195 -29
  61. package/dist/tools/timeout.js +35 -0
  62. package/dist/tools/util.js +10 -0
  63. package/dist/tools/write.js +6 -4
  64. package/dist/trust.js +89 -0
  65. package/dist/ui/app.js +228 -31
  66. package/dist/ui/banner.js +4 -9
  67. package/dist/ui/brain-wizard.js +2 -2
  68. package/dist/ui/history.js +30 -0
  69. package/dist/ui/mentions.js +44 -0
  70. package/dist/ui/render.js +55 -15
  71. package/dist/ui/setup.js +97 -12
  72. package/dist/ui/useEditor.js +83 -0
  73. package/dist/update.js +114 -0
  74. package/dist/worktree.js +173 -0
  75. package/package.json +11 -5
  76. package/scripts/postinstall.mjs +33 -0
  77. package/second-brain/.agents/_Index.md +30 -0
  78. package/second-brain/.agents/skills/_Index.md +30 -0
  79. package/second-brain/.agents/workflows/_Index.md +30 -0
  80. package/second-brain/AGENTS.md +4 -4
  81. package/second-brain/Acceptance/_Index.md +30 -0
  82. package/second-brain/Acceptance/golden-case-template.md +39 -0
  83. package/second-brain/Areas/_Index.md +30 -0
  84. package/second-brain/Bugs/System-OS/_Index.md +30 -0
  85. package/second-brain/Bugs/_Index.md +30 -0
  86. package/second-brain/CLAUDE.md +4 -1
  87. package/second-brain/Checklists/_Index.md +30 -0
  88. package/second-brain/Checklists/preflight-postflight-template.md +29 -0
  89. package/second-brain/Distillations/_Index.md +30 -0
  90. package/second-brain/Entities/_Index.md +30 -0
  91. package/second-brain/Entities/entity-template.md +33 -0
  92. package/second-brain/Evals/_Index.md +30 -0
  93. package/second-brain/Evals/correction-pairs.md +24 -0
  94. package/second-brain/Evals/failure-taxonomy.md +24 -0
  95. package/second-brain/Evals/golden-set.md +25 -0
  96. package/second-brain/Evals/quality-ledger.md +23 -0
  97. package/second-brain/Evals/self-eval-rubric.md +23 -0
  98. package/second-brain/GEMINI.md +4 -4
  99. package/second-brain/Goals/_Index.md +30 -0
  100. package/second-brain/Handoffs/_Index.md +30 -0
  101. package/second-brain/Home.md +7 -0
  102. package/second-brain/Intake/Raw Sources/_Index.md +30 -0
  103. package/second-brain/Intake/_Index.md +30 -0
  104. package/second-brain/Intake/_Quarantine/_Index.md +30 -0
  105. package/second-brain/Learning/_Index.md +30 -0
  106. package/second-brain/Playbooks/_Index.md +30 -0
  107. package/second-brain/Playbooks/playbook-template.md +23 -0
  108. package/second-brain/Projects/_Index.md +30 -0
  109. package/second-brain/Prompts/_Index.md +30 -0
  110. package/second-brain/README.md +2 -1
  111. package/second-brain/Research/_Index.md +30 -0
  112. package/second-brain/Retrospectives/_Index.md +30 -0
  113. package/second-brain/Reviews/_Index.md +30 -0
  114. package/second-brain/Runbooks/_Index.md +30 -0
  115. package/second-brain/Runbooks/eval-loop.md +24 -0
  116. package/second-brain/Sessions/_Index.md +30 -0
  117. package/second-brain/Shared/AI-Context-Index.md +20 -0
  118. package/second-brain/Shared/AI-Threads/_Index.md +30 -0
  119. package/second-brain/Shared/Archive/_Index.md +30 -0
  120. package/second-brain/Shared/Assets/_Index.md +30 -0
  121. package/second-brain/Shared/Context-Packs/_Index.md +30 -0
  122. package/second-brain/Shared/Context7-Docs/_Index.md +30 -0
  123. package/second-brain/Shared/Coordination/NOW.md +28 -0
  124. package/second-brain/Shared/Coordination/_Index.md +30 -0
  125. package/second-brain/Shared/Coordination/agent-registry.md +24 -0
  126. package/second-brain/Shared/Coordination/task-board/_Index.md +30 -0
  127. package/second-brain/Shared/Coordination/task-board/task-template.md +43 -0
  128. package/second-brain/Shared/Coordination/task-board.md +32 -0
  129. package/second-brain/Shared/Core-Facts/_Index.md +30 -0
  130. package/second-brain/Shared/Decision-Memory/_Index.md +30 -0
  131. package/second-brain/Shared/Glossary/_Index.md +30 -0
  132. package/second-brain/Shared/Memory-Inbox/_Index.md +30 -0
  133. package/second-brain/Shared/Operating-State/_Index.md +30 -0
  134. package/second-brain/Shared/Prompting/_Index.md +30 -0
  135. package/second-brain/Shared/Provenance/_Index.md +30 -0
  136. package/second-brain/Shared/Rules/_Index.md +30 -0
  137. package/second-brain/Shared/Rules/contextual-note-rule.md +30 -0
  138. package/second-brain/Shared/Rules/frontmatter-standard.md +10 -0
  139. package/second-brain/Shared/Rules/memory-write-protocol.md +28 -0
  140. package/second-brain/Shared/Rules/procedural-runbook-header.md +40 -0
  141. package/second-brain/Shared/Rules/review-and-staleness-policy.md +22 -0
  142. package/second-brain/Shared/Rules/rules-formatting.md +34 -0
  143. package/second-brain/Shared/Scripts/_Index.md +30 -0
  144. package/second-brain/Shared/Scripts-Archive/_Index.md +30 -0
  145. package/second-brain/Shared/Tech-Standards/_Index.md +30 -0
  146. package/second-brain/Shared/Tech-Standards/verification-standard.md +40 -0
  147. package/second-brain/Shared/User-Memory/_Index.md +30 -0
  148. package/second-brain/Shared/User-Persona/_Index.md +30 -0
  149. package/second-brain/Shared/User-Persona/owner-profile.md +25 -0
  150. package/second-brain/Shared/Working-Memory/_Index.md +30 -0
  151. package/second-brain/Shared/_Index.md +30 -0
  152. package/second-brain/Shared/mcp-servers/_Index.md +30 -0
  153. package/second-brain/Skills/_Index.md +30 -0
  154. package/second-brain/Templates/_Index.md +30 -0
  155. package/second-brain/Templates/bug.md +2 -0
  156. package/second-brain/Templates/handoff.md +2 -0
  157. package/second-brain/Templates/session.md +2 -0
  158. package/second-brain/Tools/_Index.md +30 -0
  159. package/second-brain/Traces/_Index.md +30 -0
  160. package/second-brain/Vault Structure Map.md +33 -1
  161. package/second-brain/copilot/_Index.md +30 -0
  162. package/skills/audit-license-compliance/SKILL.md +117 -0
  163. package/skills/author-codemod/SKILL.md +110 -0
  164. package/skills/build-audit-logging/SKILL.md +112 -0
  165. package/skills/build-cdc-streaming-pipeline/SKILL.md +123 -0
  166. package/skills/build-cli-tool/SKILL.md +108 -0
  167. package/skills/build-data-table/SKILL.md +141 -0
  168. package/skills/build-native-mobile-ui/SKILL.md +154 -0
  169. package/skills/build-offline-first-sync/SKILL.md +118 -0
  170. package/skills/build-realtime-channel/SKILL.md +122 -0
  171. package/skills/build-vector-search/SKILL.md +131 -0
  172. package/skills/compose-local-dev-stack/SKILL.md +149 -0
  173. package/skills/configure-bundler-build/SKILL.md +166 -0
  174. package/skills/configure-dns-tls/SKILL.md +142 -0
  175. package/skills/configure-reverse-proxy-lb/SKILL.md +129 -0
  176. package/skills/configure-security-headers-csp/SKILL.md +122 -0
  177. package/skills/contract-testing/SKILL.md +140 -0
  178. package/skills/datetime-timezone-correctness/SKILL.md +125 -0
  179. package/skills/debug-ci-pipeline-failure/SKILL.md +134 -0
  180. package/skills/debug-flaky-tests/SKILL.md +128 -0
  181. package/skills/defend-llm-prompt-injection/SKILL.md +110 -0
  182. package/skills/deliver-webhooks/SKILL.md +116 -0
  183. package/skills/design-api-pagination/SKILL.md +144 -0
  184. package/skills/design-authorization-model/SKILL.md +119 -0
  185. package/skills/design-backup-dr-recovery/SKILL.md +113 -0
  186. package/skills/design-event-sourcing-cqrs/SKILL.md +143 -0
  187. package/skills/design-multi-tenancy/SKILL.md +100 -0
  188. package/skills/design-protobuf-grpc-service/SKILL.md +146 -0
  189. package/skills/design-relational-schema/SKILL.md +129 -0
  190. package/skills/design-search-index-infra/SKILL.md +151 -0
  191. package/skills/design-state-machine/SKILL.md +108 -0
  192. package/skills/design-token-system/SKILL.md +109 -0
  193. package/skills/distributed-locks-leases/SKILL.md +120 -0
  194. package/skills/encrypt-sensitive-data/SKILL.md +148 -0
  195. package/skills/feature-flags-rollout/SKILL.md +130 -0
  196. package/skills/file-upload-object-storage/SKILL.md +107 -0
  197. package/skills/fuzz-dynamic-security-test/SKILL.md +111 -0
  198. package/skills/harden-llm-app-reliability/SKILL.md +126 -0
  199. package/skills/i18n-localization-setup/SKILL.md +113 -0
  200. package/skills/idempotency-keys/SKILL.md +107 -0
  201. package/skills/implement-push-notifications/SKILL.md +142 -0
  202. package/skills/ingest-webhook-secure/SKILL.md +120 -0
  203. package/skills/integrate-oauth-oidc/SKILL.md +126 -0
  204. package/skills/load-stress-test/SKILL.md +129 -0
  205. package/skills/map-privacy-data-gdpr/SKILL.md +146 -0
  206. package/skills/model-nosql-data/SKILL.md +118 -0
  207. package/skills/money-decimal-arithmetic/SKILL.md +123 -0
  208. package/skills/monitor-ml-drift/SKILL.md +109 -0
  209. package/skills/numeric-precision-units/SKILL.md +144 -0
  210. package/skills/optimize-llm-cost-latency/SKILL.md +103 -0
  211. package/skills/optimize-react-rerenders/SKILL.md +124 -0
  212. package/skills/orchestrate-agent-workflow/SKILL.md +100 -0
  213. package/skills/payments-billing-integration/SKILL.md +114 -0
  214. package/skills/pin-toolchain-versions/SKILL.md +116 -0
  215. package/skills/plan-strangler-migration/SKILL.md +95 -0
  216. package/skills/property-based-testing/SKILL.md +108 -0
  217. package/skills/publish-package-registry/SKILL.md +130 -0
  218. package/skills/recover-git-state/SKILL.md +119 -0
  219. package/skills/remediate-web-vulnerabilities/SKILL.md +125 -0
  220. package/skills/resilience-timeouts-retries/SKILL.md +104 -0
  221. package/skills/resolve-merge-rebase-conflict/SKILL.md +97 -0
  222. package/skills/rewrite-git-history/SKILL.md +109 -0
  223. package/skills/scaffold-cross-platform-app/SKILL.md +137 -0
  224. package/skills/schema-evolution-compatibility/SKILL.md +121 -0
  225. package/skills/send-transactional-email/SKILL.md +126 -0
  226. package/skills/serve-deploy-ml-model/SKILL.md +107 -0
  227. package/skills/setup-cdn-edge-waf/SKILL.md +107 -0
  228. package/skills/setup-devcontainer-env/SKILL.md +131 -0
  229. package/skills/setup-lint-format-precommit/SKILL.md +140 -0
  230. package/skills/setup-monorepo-tooling/SKILL.md +125 -0
  231. package/skills/ship-mobile-app-store-release/SKILL.md +137 -0
  232. package/skills/structured-output-llm/SKILL.md +86 -0
  233. package/skills/supply-chain-sbom-provenance/SKILL.md +120 -0
  234. package/skills/test-data-factories/SKILL.md +158 -0
  235. package/skills/threat-model-stride/SKILL.md +123 -0
  236. package/skills/train-evaluate-ml-model/SKILL.md +109 -0
  237. package/skills/unicode-text-correctness/SKILL.md +109 -0
  238. package/skills/visual-regression-testing/SKILL.md +120 -0
@@ -1,15 +1,34 @@
1
1
  import { tool } from 'ai';
2
2
  import { z } from 'zod';
3
3
  import { readFile } from 'node:fs/promises';
4
- import { clamp } from './util.js';
4
+ import { clamp, resolveAgentPath } from './util.js';
5
+ import { checkReadPath } from './permission.js';
5
6
  export const readFileTool = tool({
6
- description: 'อ่านไฟล์ใน workspace แล้วคืนเนื้อหา (UTF-8). อ่านก่อนแก้ไฟล์เสมอ',
7
+ description: 'อ่านไฟล์ใน workspace (UTF-8). อ่านก่อนแก้ไฟล์เสมอ. ' +
8
+ 'ไฟล์ใหญ่หรือต้องการแค่บางส่วน → ใส่ offset/limit อ่านเฉพาะช่วงบรรทัด (ประหยัด token มาก — คู่กับ grep ที่ให้เลขบรรทัด)',
7
9
  inputSchema: z.object({
8
10
  path: z.string().describe('relative หรือ absolute path ของไฟล์ที่จะอ่าน'),
11
+ offset: z.number().int().min(1).optional().describe('บรรทัดเริ่ม (1-based) — อ่านเฉพาะช่วง ไม่ใส่ = ต้นไฟล์'),
12
+ limit: z.number().int().min(1).optional().describe('จำนวนบรรทัดจาก offset — ไม่ใส่ = ถึงท้ายไฟล์'),
9
13
  }),
10
- execute: async ({ path }) => {
14
+ execute: async ({ path, offset, limit }) => {
15
+ const full = resolveAgentPath(path); // relative ผูกกับ agentCwd (worktree ของ sub-agent ถ้ามี)
16
+ const guard = await checkReadPath(full);
17
+ if (!guard.ok)
18
+ return `BLOCKED: ${guard.reason}`;
11
19
  try {
12
- return clamp(await readFile(path, 'utf8'));
20
+ const content = await readFile(full, 'utf8');
21
+ // ไม่ระบุช่วง → คืนทั้งไฟล์ (clamp) เหมือนเดิม
22
+ if (offset == null && limit == null)
23
+ return clamp(content);
24
+ // ระบุช่วง → อ่านเฉพาะบรรทัด start..end (ส่งเฉพาะที่ต้องการเข้า context, ประหยัด token)
25
+ const lines = content.split('\n');
26
+ const start = Math.max(0, (offset ?? 1) - 1);
27
+ if (start >= lines.length)
28
+ return `(ไฟล์มี ${lines.length} บรรทัด — offset ${offset} เกินช่วง)`;
29
+ const end = limit == null ? lines.length : Math.min(lines.length, start + limit);
30
+ const slice = lines.slice(start, end).join('\n');
31
+ return clamp(`[บรรทัด ${start + 1}-${end} จาก ${lines.length}]\n${slice}`);
13
32
  }
14
33
  catch (err) {
15
34
  return `ERROR: อ่านไฟล์ "${path}" ไม่ได้ — ${err.message}`;
@@ -3,7 +3,7 @@ import { z } from 'zod';
3
3
  import { appendMemory } from '../memory.js';
4
4
  export const rememberTool = tool({
5
5
  description: 'จำข้อเท็จจริง/preference/decision สำคัญข้าม session — ใช้เมื่อเจอสิ่งที่ควรจำไว้ใช้ครั้งหน้า ' +
6
- '(เช่น user ชอบ/ไม่ชอบอะไร, decision สำคัญ, convention ของ project). บันทึกลง ~/.sanook/memory',
6
+ '(เช่น user ชอบ/ไม่ชอบอะไร, decision สำคัญ, convention ของ project). บันทึกลง ~/.sanook/memory + route เข้า second-brain vault (Memory-Inbox) ถ้าตั้งไว้',
7
7
  inputSchema: z.object({
8
8
  fact: z.string().describe('สิ่งที่ต้องจำ — 1 ประโยคกระชับ atomic'),
9
9
  }),
@@ -0,0 +1,61 @@
1
+ import { platform, tmpdir } from 'node:os';
2
+ import { existsSync, realpathSync } from 'node:fs';
3
+ import { resolve } from 'node:path';
4
+ import { getBrainPath } from '../memory.js';
5
+ import { BRAND_ENV, envFlag } from '../brand.js';
6
+ // OS-level sandbox สำหรับ run_bash — confine "write" ให้อยู่ใน workspace (cwd + brain + tmp)
7
+ // ให้สอดคล้องกับ file-tool ที่ confine อยู่แล้ว (bash เคยเป็นช่องโหว่). อ่าน/network = ปกติ (ไม่ break build/test)
8
+ // macOS → sandbox-exec (Seatbelt) Linux → bwrap (bubblewrap) ถ้ามี
9
+ // ปิด: SANOOK_NO_SANDBOX=1 · SANOOK_ALLOW_OUTSIDE_WORKSPACE=1 (อนุญาตนอก workspace อยู่แล้ว = ไม่ sandbox)
10
+ function canon(p) {
11
+ try {
12
+ return realpathSync(p);
13
+ }
14
+ catch {
15
+ return resolve(p);
16
+ }
17
+ }
18
+ function seatbeltProfile(writable) {
19
+ const allow = writable.map((w) => ` (subpath ${JSON.stringify(w)})`).join('\n');
20
+ return [
21
+ '(version 1)',
22
+ '(allow default)',
23
+ '(deny file-write*)',
24
+ '(allow file-write*',
25
+ allow,
26
+ ' (subpath "/dev")',
27
+ ' (literal "/dev/null") (literal "/dev/stdout") (literal "/dev/stderr"))',
28
+ ].join('\n');
29
+ }
30
+ function bwrapArgs(writable, cmd) {
31
+ const binds = writable.flatMap((w) => ['--bind', w, w]);
32
+ return ['--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', ...binds, '/bin/sh', '-c', cmd];
33
+ }
34
+ /**
35
+ * คืน {file,args} สำหรับรัน cmd แบบ sandbox (ผ่าน execFile) — หรือ null ถ้าไม่มี sandbox/ปิดไว้
36
+ * (caller รัน cmd ตรงๆ ตามเดิม). path ที่มี '"' → ข้าม sandbox (กัน profile พัง)
37
+ */
38
+ export async function maybeSandbox(cmd, cwd = process.cwd()) {
39
+ if (envFlag(BRAND_ENV.allowOutsideWorkspace) || envFlag('SANOOK_NO_SANDBOX'))
40
+ return null;
41
+ const writable = [canon(cwd), canon(tmpdir())];
42
+ const brain = await getBrainPath().catch(() => null);
43
+ if (brain && existsSync(brain))
44
+ writable.push(canon(brain));
45
+ if (writable.some((w) => w.includes('"')))
46
+ return null;
47
+ const os = platform();
48
+ if (os === 'darwin') {
49
+ const bin = ['/usr/bin/sandbox-exec', '/usr/sbin/sandbox-exec'].find((p) => existsSync(p));
50
+ if (!bin)
51
+ return null;
52
+ return { file: bin, args: ['-p', seatbeltProfile(writable), '/bin/sh', '-c', cmd] };
53
+ }
54
+ if (os === 'linux') {
55
+ const bin = ['/usr/bin/bwrap', '/bin/bwrap'].find((p) => existsSync(p));
56
+ if (!bin)
57
+ return null;
58
+ return { file: bin, args: bwrapArgs(writable, cmd) };
59
+ }
60
+ return null;
61
+ }
@@ -1,11 +1,98 @@
1
1
  import { tool } from 'ai';
2
2
  import { z } from 'zod';
3
- import { glob } from 'node:fs/promises';
3
+ import { glob, readdir, stat, readFile } from 'node:fs/promises';
4
4
  import { execFile } from 'node:child_process';
5
+ import { isAbsolute, join, relative } from 'node:path';
5
6
  import { promisify } from 'node:util';
6
- import { clamp } from './util.js';
7
+ import { clamp, resolveAgentPath } from './util.js';
8
+ import { checkReadPath } from './permission.js';
9
+ import { agentCwd } from '../agentContext.js';
10
+ // pure-JS grep fallback — ใช้เมื่อ ripgrep (rg) ไม่ได้ติดตั้ง (เช่น Windows สะอาด) → grep ใช้ได้ทุกแพลตฟอร์ม
11
+ const FALLBACK_IGNORE = new Set(['node_modules', '.git', 'dist', 'build', 'coverage', '.next', '.cache', '.turbo', '.vercel', 'vendor']);
12
+ const FALLBACK_MAX_FILE = 2 * 1024 * 1024; // ข้ามไฟล์ใหญ่ (กันช้า/binary)
13
+ const PER_FILE_CAP = 50; // เหมือน rg --max-count 50
14
+ export async function jsGrep(pattern, base, target) {
15
+ let re;
16
+ try {
17
+ re = new RegExp(pattern); // rg ใช้ Rust regex; JS regex ใกล้เคียงพอสำหรับ pattern ทั่วไป
18
+ }
19
+ catch {
20
+ return `ERROR: grep regex ไม่ถูกต้อง: "${pattern}"`;
21
+ }
22
+ const root = isAbsolute(target) ? target : join(base, target);
23
+ const out = [];
24
+ const scanFile = async (full) => {
25
+ let s;
26
+ try {
27
+ s = await stat(full);
28
+ }
29
+ catch {
30
+ return;
31
+ }
32
+ if (s.size > FALLBACK_MAX_FILE)
33
+ return;
34
+ let content;
35
+ try {
36
+ content = await readFile(full, 'utf8');
37
+ }
38
+ catch {
39
+ return;
40
+ }
41
+ if (content.includes('\u0000'))
42
+ return; // binary
43
+ const rel = relative(base, full) || full;
44
+ const lines = content.split(/\r?\n/);
45
+ let perFile = 0;
46
+ for (let i = 0; i < lines.length && out.length < MAX_RESULTS; i++) {
47
+ if (re.test(lines[i])) {
48
+ out.push(`${rel}:${i + 1}:${lines[i].slice(0, 300)}`);
49
+ if (++perFile >= PER_FILE_CAP)
50
+ break;
51
+ }
52
+ }
53
+ };
54
+ const walk = async (dir) => {
55
+ if (out.length >= MAX_RESULTS)
56
+ return;
57
+ let entries;
58
+ try {
59
+ entries = await readdir(dir, { withFileTypes: true });
60
+ }
61
+ catch {
62
+ return;
63
+ }
64
+ for (const e of entries) {
65
+ if (out.length >= MAX_RESULTS)
66
+ return;
67
+ if (e.isDirectory()) {
68
+ if (!FALLBACK_IGNORE.has(e.name) && !e.name.startsWith('.'))
69
+ await walk(join(dir, e.name));
70
+ }
71
+ else if (e.isFile()) {
72
+ await scanFile(join(dir, e.name));
73
+ }
74
+ }
75
+ };
76
+ let st;
77
+ try {
78
+ st = await stat(root);
79
+ }
80
+ catch {
81
+ return `ERROR: grep path ไม่พบ: "${target}"`;
82
+ }
83
+ if (st.isFile())
84
+ await scanFile(root);
85
+ else
86
+ await walk(root);
87
+ if (!out.length)
88
+ return '(no matches)';
89
+ return `${clamp(out.join('\n'))}\n[JS fallback — ติดตั้ง ripgrep (rg) เพื่อความเร็ว + เคารพ .gitignore: brew/apt/choco/scoop install ripgrep]`;
90
+ }
7
91
  const execFileAsync = promisify(execFile);
8
92
  const MAX_RESULTS = 200;
93
+ function unsafeGlobPattern(pattern) {
94
+ return isAbsolute(pattern) || pattern.split(/[\\/]+/).includes('..');
95
+ }
9
96
  export const globTool = tool({
10
97
  description: 'หาไฟล์ด้วย glob pattern (เช่น "src/**/*.ts", "**/*.json")',
11
98
  inputSchema: z.object({
@@ -13,9 +100,16 @@ export const globTool = tool({
13
100
  cwd: z.string().default('.').describe('directory ที่จะค้นจาก'),
14
101
  }),
15
102
  execute: async ({ pattern, cwd }) => {
103
+ if (unsafeGlobPattern(pattern)) {
104
+ return `BLOCKED: glob pattern ต้องเป็น relative path ภายใน cwd และห้ามมี "..": "${pattern}"`;
105
+ }
106
+ const base = resolveAgentPath(cwd); // '.' → agentCwd (worktree ของ sub-agent ถ้ามี)
107
+ const guard = await checkReadPath(base);
108
+ if (!guard.ok)
109
+ return `BLOCKED: ${guard.reason}`;
16
110
  try {
17
111
  const out = [];
18
- for await (const f of glob(pattern, { cwd })) {
112
+ for await (const f of glob(pattern, { cwd: base })) {
19
113
  out.push(f);
20
114
  if (out.length >= MAX_RESULTS) {
21
115
  out.push(`... [>${MAX_RESULTS} matches, truncated]`);
@@ -36,10 +130,14 @@ export const grepTool = tool({
36
130
  path: z.string().default('.').describe('directory หรือไฟล์ที่จะค้น'),
37
131
  }),
38
132
  execute: async ({ pattern, path }) => {
133
+ const base = agentCwd(); // รัน rg ใน worktree ของ sub-agent ถ้ามี → path relative ผูกถูก tree
134
+ const guard = await checkReadPath(resolveAgentPath(path));
135
+ if (!guard.ok)
136
+ return `BLOCKED: ${guard.reason}`;
39
137
  try {
40
138
  // execFile (args array, ไม่ผ่าน shell) → $(...)/backtick/$VAR ใน pattern/path เป็น inert
41
139
  // กัน command injection (JSON.stringify ไม่ใช่ shell quoting — เคยรั่ว); -e กัน pattern ขึ้นต้นด้วย -
42
- const { stdout } = await execFileAsync('rg', ['--line-number', '--no-heading', '--max-count', '50', '-e', pattern, '--', path], { maxBuffer: 10 * 1024 * 1024 });
140
+ const { stdout } = await execFileAsync('rg', ['--line-number', '--no-heading', '--max-count', '50', '-e', pattern, '--', path], { cwd: base, maxBuffer: 10 * 1024 * 1024 });
43
141
  const lines = stdout.trim().split('\n').slice(0, MAX_RESULTS);
44
142
  return clamp(lines.join('\n')) || '(no matches)';
45
143
  }
@@ -48,6 +146,9 @@ export const grepTool = tool({
48
146
  const e = err;
49
147
  if (e.code === 1)
50
148
  return '(no matches)';
149
+ // rg ไม่ได้ติดตั้ง (Windows สะอาด ฯลฯ) → fallback เป็น JS grep ให้ใช้ได้ทุกแพลตฟอร์ม
150
+ if (e.code === 'ENOENT')
151
+ return jsGrep(pattern, base, path);
51
152
  return `ERROR: grep "${pattern}" ล้มเหลว — ${err.message}`;
52
153
  }
53
154
  },
@@ -1,46 +1,212 @@
1
1
  import { tool } from 'ai';
2
2
  import { z } from 'zod';
3
- import { agentContext } from '../agentContext.js';
3
+ import { agentContext, agentCwd } from '../agentContext.js';
4
4
  import { approvalContext } from '../approval.js';
5
+ import { runParallel, runThunks, TaskRegistry } from '../orchestrate.js';
6
+ import { runInWorktrees, getRepoRoot } from '../worktree.js';
5
7
  // task = มอบงานย่อยให้ sub-agent ทำใน context แยก (เลียน Claude Code Task tool)
6
8
  // depth/model/budget thread ผ่าน AsyncLocalStorage (parallel-safe, ไม่ใช่ process.env)
9
+ // orchestration: task (single) · task_parallel (fan-out) · task_spawn/collect/cancel/status (background)
7
10
  const MAX_DEPTH = 2;
11
+ const MAX_FANOUT = 16; // กัน fan-out ระเบิด: 1 task_parallel call สูงสุด 16 subagents
12
+ const DEFAULT_CONCURRENCY = 5; // subagent = API-bound → คุม concurrency กัน rate-limit
13
+ const SUB_MAX_STEPS = 15;
8
14
  // read-only = อ่าน/ค้นเท่านั้น — ตัด run_bash ออก (shell = เลี่ยง read-only contract ได้)
9
- const READ_TOOLS = ['read_file', 'list_dir', 'glob', 'grep', 'git_status', 'git_diff', 'git_log', 'skill', 'find_skills'];
10
- // sub-agent ห้ามมี: task (recursion), scheduling (side-effect ที่ควรเป็น main agent)
11
- const SUBAGENT_EXCLUDE = ['task', 'schedule_task', 'list_scheduled', 'cancel_scheduled'];
12
- export const taskTool = tool({
13
- description: 'มอบงานย่อยให้ sub-agent ทำใน context แยก ใช้ตอนต้องสำรวจหลายไฟล์/ค้นหาเยอะแล้วอยากได้แค่บทสรุป ' +
14
- '(กัน context หลักบวม). sub-agent เริ่มสะอาด ไม่เห็น conversation นี้ → เขียน prompt ให้ครบในตัว. ' +
15
- 'default read-only (อ่าน/ค้น); readonly=false ให้แก้ไฟล์/รัน bash ได้ด้วย',
16
- inputSchema: z.object({
17
- description: z.string().describe('สรุปงาน 3-5 คำ'),
18
- prompt: z.string().describe('คำสั่งเต็ม self-contained ให้ sub-agent (มันไม่เห็น context นี้)'),
19
- readonly: z.boolean().optional().describe('true (default) = อ่าน/ค้นเท่านั้น; false = แก้ไฟล์/bash ได้'),
20
- }),
21
- execute: async ({ prompt, readonly = true }) => {
22
- const ctx = agentContext.getStore();
23
- const depth = ctx?.depth ?? 0;
24
- if (depth >= MAX_DEPTH) {
25
- return 'ถึงขีดจำกัดความลึก sub-agent แล้ว (กัน spawn ไม่จบ) — ทำงานนี้เองแทน';
26
- }
15
+ // 'task'/'task_parallel' อยู่ใน set nested orchestration ได้ (depth cap กันไม่จบ)
16
+ const READ_TOOLS = ['read_file', 'list_dir', 'glob', 'grep', 'git_status', 'git_diff', 'git_log', 'recall', 'skill', 'find_skills', 'task', 'task_parallel'];
17
+ // sub-agent ห้ามมี: scheduling + background orchestration (เป็น side-effect ของ main agent — detached task ที่ subagent spawn จะ outlive มันงงๆ)
18
+ const SUBAGENT_EXCLUDE = ['schedule_task', 'list_scheduled', 'cancel_scheduled', 'task_spawn', 'task_collect', 'task_cancel', 'task_status'];
19
+ // registry ของ background taskอยู่ระดับ process (อยู่ข้าม tool call ใน session เดียว)
20
+ const registry = new TaskRegistry();
21
+ /** snapshot ของ parent context ตอนเรียก tool (sync, ก่อน await) ส่งต่อให้ subagent ทั้ง parallel + background */
22
+ function parentCtx() {
23
+ const ctx = agentContext.getStore();
24
+ const appr = approvalContext.getStore();
25
+ return { model: ctx?.model, budgetUsd: ctx?.budgetUsd, depth: ctx?.depth ?? 0, cwd: ctx?.cwd, mode: appr?.mode ?? 'ask', approve: appr?.approve };
26
+ }
27
+ /**
28
+ * real subagent runner — รัน runAgent ใน context แยก. ครอบด้วย agentContext.run() ให้
29
+ * แต่ละ subagent (parallel/nested) มี ALS context ของตัวเอง ไม่ bleed ข้ามกัน
30
+ * (enterWith ของ runAgent อย่างเดียวไม่ isolate พอตอนรัน concurrent จาก parent เดียวกัน)
31
+ */
32
+ function makeRunner(parent) {
33
+ return async (spec, signal) => {
27
34
  const { runAgent } = await import('../loop.js');
28
35
  const { tools } = await import('./index.js');
29
36
  const entries = Object.entries(tools);
37
+ const readonly = spec.readonly ?? true;
30
38
  const picked = readonly
31
39
  ? entries.filter(([k]) => READ_TOOLS.includes(k))
32
40
  : entries.filter(([k]) => !SUBAGENT_EXCLUDE.includes(k));
33
- const appr = approvalContext.getStore();
34
- const { text } = await runAgent({
35
- model: ctx?.model ?? 'sonnet', // inherit จาก main
36
- budgetUsd: ctx?.budgetUsd, // cap เดียวกับ main (กัน sub-agent วิ่ง uncapped)
37
- subagentDepth: depth + 1, // thread depth ผ่าน param ไม่ mutate global
38
- permissionMode: appr?.mode ?? 'auto', // inherit ask-mode (กัน sub-agent เลี่ยง approval)
39
- approve: appr?.approve,
40
- prompt,
41
- maxSteps: 15,
41
+ // model: explicit spec ก่อน → SANOOK_SUBAGENT_MODEL (opt-in: route งาน subagent ไป model ถูกกว่า เช่น haiku
42
+ // สำหรับ exploration/search ที่เป็นงานกลไก ประหยัด cost มาก โดย quality หลักไม่กระทบ) → inherit จาก parent
43
+ const model = spec.model ?? process.env.SANOOK_SUBAGENT_MODEL ?? parent.model ?? 'sonnet';
44
+ const depth = parent.depth + 1;
45
+ const cwd = spec.cwd ?? parent.cwd; // worktree ของ subagent นี้ (ถ้า isolate) ไม่งั้น inherit
46
+ const childStore = { model, budgetUsd: parent.budgetUsd, depth, cwd };
47
+ const { text } = await agentContext.run(childStore, () => runAgent({
48
+ model,
49
+ budgetUsd: parent.budgetUsd, // cap เดียวกับ main (กัน subagent วิ่ง uncapped)
50
+ subagentDepth: depth,
51
+ cwd, // file ops ของ subagent ผูกกับ worktree นี้ (isolation)
52
+ permissionMode: parent.mode, // inherit ask-mode (กัน subagent เลี่ยง approval)
53
+ approve: parent.approve,
54
+ prompt: spec.prompt,
55
+ maxSteps: SUB_MAX_STEPS,
56
+ signal,
42
57
  tools: Object.fromEntries(picked),
43
- });
58
+ }));
44
59
  return text || '(sub-agent ไม่มีผลลัพธ์)';
60
+ };
61
+ }
62
+ const atDepthLimit = (parent) => parent.depth >= MAX_DEPTH;
63
+ const DEPTH_MSG = 'ถึงขีดจำกัดความลึก sub-agent แล้ว (กัน spawn ไม่จบ) — ทำงานนี้เองแทน';
64
+ const taskInput = {
65
+ description: z.string().describe('สรุปงาน 3-5 คำ'),
66
+ prompt: z.string().describe('คำสั่งเต็ม self-contained ให้ sub-agent (มันไม่เห็น context นี้)'),
67
+ readonly: z.boolean().optional().describe('true (default) = อ่าน/ค้นเท่านั้น; false = แก้ไฟล์/bash ได้'),
68
+ };
69
+ export const taskTool = tool({
70
+ description: 'มอบงานย่อย 1 ชิ้นให้ sub-agent ทำใน context แยก — ใช้ตอนต้องสำรวจหลายไฟล์/ค้นหาเยอะแล้วอยากได้แค่บทสรุป ' +
71
+ '(กัน context หลักบวม). sub-agent เริ่มสะอาด ไม่เห็น conversation นี้ → เขียน prompt ให้ครบในตัว. ' +
72
+ 'default read-only (อ่าน/ค้น); readonly=false ให้แก้ไฟล์/รัน bash ได้. หลายชิ้นพร้อมกัน → ใช้ task_parallel',
73
+ inputSchema: z.object(taskInput),
74
+ execute: async ({ description, prompt, readonly = true }) => {
75
+ const parent = parentCtx();
76
+ if (atDepthLimit(parent))
77
+ return DEPTH_MSG;
78
+ const runner = makeRunner(parent);
79
+ const [outcome] = await runParallel([{ description, prompt, readonly }], runner);
80
+ return outcome.ok ? outcome.text : `sub-agent ล้มเหลว: ${outcome.error}`;
81
+ },
82
+ });
83
+ /** จัดรูปผลของ subagent หลายตัว */
84
+ function formatOutcomes(outcomes) {
85
+ const okN = outcomes.filter((o) => o.ok).length;
86
+ const head = `${outcomes.length} subagents (${okN} สำเร็จ, ${outcomes.length - okN} ล้มเหลว):`;
87
+ const body = outcomes
88
+ .map((o, i) => `\n## [${i + 1}] ${o.description} ${o.ok ? '✓' : '✗'}\n${o.ok ? o.text : `error: ${o.error}`}`)
89
+ .join('\n');
90
+ return `${head}\n${body}`;
91
+ }
92
+ /**
93
+ * isolate mode — subagent ที่ "เขียนไฟล์" รันใน git worktree ของตัวเอง (จาก HEAD) ไม่ชนกัน
94
+ * แล้ว capture diff แต่ละ worktree → apply กลับ main tree แบบ sequential (ชน = รายงาน ไม่ทับเงียบ).
95
+ * worktree lifecycle อยู่ใน worktree.ts (testable); ตรงนี้แค่ผูก subagent runner เข้าไป
96
+ */
97
+ async function runIsolated(specs, parent, concurrency) {
98
+ const root = await getRepoRoot(parent.cwd ?? agentCwd());
99
+ if (!root)
100
+ return 'isolate=worktree ต้องอยู่ใน git repo — ใช้ task_parallel แบบปกติแทน (ไม่มี worktree)';
101
+ const runner = makeRunner(parent);
102
+ const runs = await runInWorktrees(specs, root,
103
+ // งานต่อ subagent: รันใน worktree (cwd) ของมัน, readonly=false (isolate มีไว้ให้แก้ไฟล์)
104
+ (spec, cwd) => runner({ ...spec, cwd, readonly: spec.readonly ?? false }, undefined)
105
+ .then((text) => ({ ok: true, description: spec.description, text }))
106
+ .catch((e) => ({ ok: false, description: spec.description, text: '', error: e.message })), (thunks) => runThunks(thunks, concurrency));
107
+ if (!runs)
108
+ return 'สร้าง git worktree ไม่สำเร็จ (หรือไม่ใช่ git repo) — ยกเลิก isolate';
109
+ const outcomes = runs.map((r) => r.result);
110
+ const mergeNotes = runs.map((r, i) => {
111
+ const m = r.merge;
112
+ if (!m.changed.length)
113
+ return `[${i + 1}] ${m.description}: ไม่มีการแก้ไฟล์`;
114
+ return m.applied
115
+ ? `[${i + 1}] ${m.description}: merge แล้ว — ${m.changed.length} ไฟล์ (${m.changed.slice(0, 8).join(', ')})`
116
+ : `[${i + 1}] ${m.description}: ⚠ merge ชน — ${m.reason}; ไฟล์: ${m.changed.join(', ')} (แก้ conflict เอง)`;
117
+ });
118
+ return `${formatOutcomes(outcomes)}\n\n--- worktree merge → main tree ---\n${mergeNotes.join('\n')}`;
119
+ }
120
+ export const taskParallelTool = tool({
121
+ description: 'มอบงานย่อยหลายชิ้นให้ sub-agent ทำ "พร้อมกัน" (fan-out) — ใช้เมื่องานแตกเป็นส่วนๆ ที่ไม่ขึ้นต่อกัน ' +
122
+ '(เช่น สำรวจหลายโมดูล / review หลายมิติ / ค้นหลายมุม). คืนผลรวมทุกตัว (ตัวล้มไม่ทำให้ทั้ง batch ล้ม). ' +
123
+ `สูงสุด ${MAX_FANOUT} ชิ้น/ครั้ง. แต่ละชิ้นเขียน prompt ให้ครบในตัว (subagent ไม่เห็น context นี้). ` +
124
+ 'isolate=true → subagent ที่แก้ไฟล์รันใน git worktree แยกกัน (ไม่ชนไฟล์) แล้ว merge กลับให้',
125
+ inputSchema: z.object({
126
+ tasks: z.array(z.object(taskInput)).min(1).max(MAX_FANOUT).describe('รายการงานย่อยที่จะรันพร้อมกัน'),
127
+ concurrency: z.number().int().min(1).max(MAX_FANOUT).optional().describe(`จำนวนที่รันพร้อมกันสูงสุด (default ${DEFAULT_CONCURRENCY})`),
128
+ isolate: z.boolean().optional().describe('true = รัน subagent ที่เขียนไฟล์ใน git worktree แยก (กันชนไฟล์) แล้ว merge กลับ — ต้องอยู่ใน git repo'),
129
+ }),
130
+ execute: async ({ tasks, concurrency, isolate }) => {
131
+ const parent = parentCtx();
132
+ if (atDepthLimit(parent))
133
+ return DEPTH_MSG;
134
+ const specs = tasks.map((t) => ({ description: t.description, prompt: t.prompt, readonly: t.readonly ?? true }));
135
+ const cc = concurrency ?? DEFAULT_CONCURRENCY;
136
+ if (isolate)
137
+ return runIsolated(specs, parent, cc);
138
+ const outcomes = await runParallel(specs, makeRunner(parent), { concurrency: cc });
139
+ return formatOutcomes(outcomes);
140
+ },
141
+ });
142
+ export const taskSpawnTool = tool({
143
+ description: 'เริ่มงานย่อยแบบ "background" — คืน task id ทันที แล้ว sub-agent ทำต่อเบื้องหลัง ขณะที่ main agent ทำอย่างอื่นต่อได้ ' +
144
+ 'เก็บผลภายหลังด้วย task_collect, ดูสถานะด้วย task_status, ยกเลิกด้วย task_cancel. เหมาะกับงานยาว (research ลึก, สแกนทั้ง repo) ที่ไม่อยากบล็อก. ' +
145
+ '(อยู่แค่ใน session นี้ — งานข้าม session ใช้ schedule_task)',
146
+ inputSchema: z.object(taskInput),
147
+ execute: async ({ description, prompt, readonly = true }) => {
148
+ const parent = parentCtx();
149
+ if (atDepthLimit(parent))
150
+ return DEPTH_MSG;
151
+ const id = registry.spawn({ description, prompt, readonly }, makeRunner(parent));
152
+ return `เริ่ม background task "${description}" แล้ว — id: ${id}. เก็บผลด้วย task_collect("${id}") · ยกเลิกด้วย task_cancel("${id}") · ดูสถานะ task_status`;
153
+ },
154
+ });
155
+ export const taskCollectTool = tool({
156
+ description: 'เก็บผลของ background task (จาก task_spawn) — ส่ง id เดียวหรือหลาย id. ' +
157
+ 'default รอจนเสร็จ; ใส่ timeoutSec เพื่อ poll แบบไม่บล็อก (ยังไม่เสร็จจะคืนสถานะ running)',
158
+ inputSchema: z.object({
159
+ ids: z.union([z.string(), z.array(z.string())]).describe('task id เดียว หรือ array ของ id'),
160
+ timeoutSec: z.number().min(0).optional().describe('รอสูงสุดกี่วินาที (ไม่ใส่ = รอจนเสร็จ)'),
161
+ }),
162
+ execute: async ({ ids, timeoutSec }) => {
163
+ const idList = Array.isArray(ids) ? ids : [ids];
164
+ const timeoutMs = timeoutSec == null ? undefined : Math.round(timeoutSec * 1000);
165
+ const recs = await Promise.all(idList.map((id) => registry.collect(id, timeoutMs)));
166
+ return recs
167
+ .map((r, i) => {
168
+ if (!r)
169
+ return `[${idList[i]}] ไม่พบ task นี้`;
170
+ if (r.state === 'done')
171
+ return `## ${r.id} ${r.description} ✓\n${r.text ?? ''}`;
172
+ if (r.state === 'error')
173
+ return `## ${r.id} ${r.description} ✗ error: ${r.error}`;
174
+ if (r.state === 'canceled')
175
+ return `## ${r.id} ${r.description} (ยกเลิกแล้ว)`;
176
+ return `## ${r.id} ${r.description} (ยังทำงานอยู่ — collect อีกครั้งภายหลัง)`;
177
+ })
178
+ .join('\n\n');
179
+ },
180
+ });
181
+ export const taskCancelTool = tool({
182
+ description: 'ยกเลิก background task ที่ยัง running อยู่ (จาก task_spawn) ด้วย AbortSignal',
183
+ inputSchema: z.object({
184
+ id: z.string().describe('task id จาก task_spawn'),
185
+ }),
186
+ execute: async ({ id }) => {
187
+ const rec = registry.get(id);
188
+ if (!rec)
189
+ return `[${id}] ไม่พบ task นี้`;
190
+ if (rec.state !== 'running')
191
+ return `[${id}] ยกเลิกไม่ได้ — สถานะปัจจุบัน: ${rec.state}`;
192
+ const ok = registry.cancel(id);
193
+ if (ok)
194
+ return `[${id}] ยกเลิกแล้ว — ${rec.description}`;
195
+ return `[${id}] ยกเลิกไม่ได้ — สถานะปัจจุบัน: ${registry.get(id)?.state ?? rec.state}`;
196
+ },
197
+ });
198
+ export const taskStatusTool = tool({
199
+ description: 'ดูสถานะ background task ทั้งหมดใน session นี้ (running/done/error/canceled)',
200
+ inputSchema: z.object({}),
201
+ execute: async () => {
202
+ const all = registry.list();
203
+ if (!all.length)
204
+ return 'ยังไม่มี background task';
205
+ return all
206
+ .map((r) => {
207
+ const elapsed = r.endedMs ? `${((r.endedMs - r.startedMs) / 1000).toFixed(1)}s` : 'running…';
208
+ return `${r.id} ${r.state.padEnd(8)} ${elapsed} — ${r.description}`;
209
+ })
210
+ .join('\n');
45
211
  },
46
212
  });
@@ -0,0 +1,35 @@
1
+ // ครอบ tool ด้วย timeout — กัน read/grep/glob/edit บนไฟล์ใหญ่ค้าง แล้วแขวน loop ทั้ง session ไม่จบ
2
+ // tool ที่จัดการ timeout เองอยู่แล้ว → ไม่ครอบ: run_bash (120s ในตัว), task (sub-agent อาจรันนาน)
3
+ const SELF_TIMED = new Set(['run_bash', 'task']);
4
+ export const DEFAULT_TOOL_TIMEOUT = 120_000;
5
+ /** Promise.race tool execute กับ timer — timeout คืนเป็น ERROR string (tool ไม่ throw เข้า loop) */
6
+ export function wrapToolsWithTimeout(tools, ms = DEFAULT_TOOL_TIMEOUT) {
7
+ const out = {};
8
+ for (const [name, t] of Object.entries(tools)) {
9
+ if (SELF_TIMED.has(name) || typeof t.execute !== 'function') {
10
+ out[name] = t;
11
+ continue;
12
+ }
13
+ const orig = t.execute;
14
+ out[name] = {
15
+ ...t,
16
+ execute: async (input, opts) => {
17
+ let timer;
18
+ const timeout = new Promise((_, reject) => {
19
+ timer = setTimeout(() => reject(new Error(`tool "${name}" ค้างเกิน ${ms}ms — ยกเลิก`)), ms);
20
+ });
21
+ try {
22
+ return await Promise.race([Promise.resolve(orig(input, opts)), timeout]);
23
+ }
24
+ catch (e) {
25
+ return `ERROR: ${e.message}`;
26
+ }
27
+ finally {
28
+ if (timer)
29
+ clearTimeout(timer);
30
+ }
31
+ },
32
+ };
33
+ }
34
+ return out;
35
+ }
@@ -1,5 +1,15 @@
1
+ import { isAbsolute, resolve } from 'node:path';
2
+ import { agentCwd } from '../agentContext.js';
1
3
  export const MAX_OUTPUT = 30_000;
2
4
  /** ตัด output ที่ยาวเกิน กัน context ระเบิด */
3
5
  export function clamp(s, max = MAX_OUTPUT) {
4
6
  return s.length > max ? s.slice(0, max) + `\n... [truncated ${s.length - max} chars]` : s;
5
7
  }
8
+ /**
9
+ * resolve path ของ tool ให้ผูกกับ working dir ของ agent ปัจจุบัน (agentCwd) ไม่ใช่ process.cwd().
10
+ * สำคัญตอน sub-agent รันใน git worktree แยก: relative path ("src/foo.ts") ต้องชี้เข้า worktree
11
+ * ของ sub-agent นั้น ไม่ใช่ main tree (ไม่งั้น isolation หลุด — แก้ผิดไฟล์). absolute path คงเดิม.
12
+ */
13
+ export function resolveAgentPath(p) {
14
+ return isAbsolute(p) ? p : resolve(agentCwd(), p);
15
+ }
@@ -3,6 +3,7 @@ import { z } from 'zod';
3
3
  import { writeFile, mkdir, readFile } from 'node:fs/promises';
4
4
  import { dirname } from 'node:path';
5
5
  import { checkWritePath } from './permission.js';
6
+ import { resolveAgentPath } from './util.js';
6
7
  import { summarizeWrite } from '../diff.js';
7
8
  export const writeFileTool = tool({
8
9
  description: 'เขียนไฟล์ใหม่ (overwrite ถ้ามีอยู่แล้ว) — สร้าง directory ให้อัตโนมัติ. ใช้สร้างไฟล์ใหม่ทั้งไฟล์ (แก้บางส่วนใช้ edit_file)',
@@ -11,13 +12,14 @@ export const writeFileTool = tool({
11
12
  content: z.string().describe('เนื้อหาทั้งหมดของไฟล์'),
12
13
  }),
13
14
  execute: async ({ path, content }) => {
14
- const guard = checkWritePath(path);
15
+ const full = resolveAgentPath(path); // relative ผูกกับ agentCwd (worktree ของ sub-agent ถ้ามี)
16
+ const guard = await checkWritePath(full);
15
17
  if (!guard.ok)
16
18
  return `BLOCKED: ${guard.reason}`;
17
- const previous = await readFile(path, 'utf8').catch(() => undefined); // มีอยู่เดิมไหม (โชว์ before→after)
19
+ const previous = await readFile(full, 'utf8').catch(() => undefined); // มีอยู่เดิมไหม (โชว์ before→after)
18
20
  try {
19
- await mkdir(dirname(path), { recursive: true });
20
- await writeFile(path, content, 'utf8');
21
+ await mkdir(dirname(full), { recursive: true });
22
+ await writeFile(full, content, 'utf8');
21
23
  return `OK: "${path}" — ${summarizeWrite(content, previous)}`;
22
24
  }
23
25
  catch (err) {