@miphamai/cli 0.81.6 → 0.81.8

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 (66) hide show
  1. package/README.md +9 -9
  2. package/bin/daemon.ts +7 -32
  3. package/bin/mipham.ts +43 -29
  4. package/package.json +5 -2
  5. package/skills/standard/mipham-code-setup.SKILL.md +3 -3
  6. package/src/agent/sub-agent.ts +12 -1
  7. package/src/artifacts/manifest.ts +90 -34
  8. package/src/artifacts/paths.ts +19 -0
  9. package/src/artifacts/server.ts +48 -8
  10. package/src/commands/project.ts +92 -12
  11. package/src/config/keys-manager.ts +10 -11
  12. package/src/config/loader.ts +82 -1
  13. package/src/config/preferences.ts +5 -2
  14. package/src/core/context.ts +10 -2
  15. package/src/core/cron-poller.ts +30 -6
  16. package/src/core/engine.ts +19 -4
  17. package/src/core/metrics.ts +8 -0
  18. package/src/core/paths.ts +79 -0
  19. package/src/core/permission-rules.ts +261 -17
  20. package/src/core/permission.ts +3 -0
  21. package/src/core/session-log.ts +55 -3
  22. package/src/core/session-store.ts +11 -1
  23. package/src/daemon/engine-capabilities.ts +131 -0
  24. package/src/daemon/index.ts +4 -1
  25. package/src/daemon/launch.ts +287 -0
  26. package/src/daemon/remote-engine.ts +2 -0
  27. package/src/daemon/server.ts +9 -0
  28. package/src/daemon/session-worker.ts +21 -3
  29. package/src/i18n-core/locales/en-US.json +6 -7
  30. package/src/i18n-core/locales/zh-CN.json +6 -7
  31. package/src/index.tsx +82 -2
  32. package/src/mcp/client.ts +4 -2
  33. package/src/plugin/plugin-manager.ts +17 -6
  34. package/src/providers/anthropic.ts +28 -2
  35. package/src/providers/openai-compat.ts +14 -1
  36. package/src/security/path.ts +6 -1
  37. package/src/shared/atomic-write.ts +28 -5
  38. package/src/shared/package-info.ts +1 -1
  39. package/src/shared/types.ts +24 -0
  40. package/src/skills/bundled-skills.ts +1 -1
  41. package/src/telemetry/consent.ts +209 -0
  42. package/src/telemetry/crash.ts +197 -0
  43. package/src/telemetry/endpoint.ts +82 -0
  44. package/src/telemetry/index.ts +153 -0
  45. package/src/telemetry/payload.ts +141 -0
  46. package/src/telemetry/queue.ts +95 -0
  47. package/src/telemetry/redact.ts +127 -0
  48. package/src/telemetry/transport.ts +81 -0
  49. package/src/tools/agent/workflow.ts +11 -4
  50. package/src/tools/artifact/artifact.ts +14 -4
  51. package/src/tools/exec/bash.ts +45 -21
  52. package/src/tools/exec/enter-worktree.ts +6 -5
  53. package/src/tools/exec/exit-worktree.ts +10 -5
  54. package/src/tools/exec/git.ts +25 -10
  55. package/src/tools/file/grep.ts +37 -13
  56. package/src/tools/file/read.ts +151 -45
  57. package/src/tools/scheduling/cron.ts +34 -5
  58. package/src/tools/system/config.ts +9 -5
  59. package/src/ui/app.tsx +40 -11
  60. package/src/ui/commands.ts +186 -45
  61. package/src/workflow/primitives/agent.ts +4 -2
  62. package/src/artifacts/versioning.ts +0 -127
  63. package/src/core/task-runner-tasks.json +0 -14
  64. package/src/core/task-runner.ts +0 -163
  65. package/src/skills/mipham/runtime.ts +0 -66
  66. package/src/skills/standard/runtime.ts +0 -62
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
- # Mipham Code v0.5.9
1
+ # Mipham Code v0.81.7
2
2
 
3
- **Multi-model open-core intelligent coding terminal** — 9 AI providers, 66 commands, 16 tools, 14 skills + marketplace, self-update. AI-assisted code generation, security auditing, MCP protocol, and extensible skills — in a single CLI.
3
+ **Multi-model open-core intelligent coding terminal** — 12 AI providers, 137 commands, 31 tools, 28 skills + marketplace, self-update. AI-assisted code generation, security auditing, MCP protocol, and extensible skills — in a single CLI.
4
4
 
5
5
  Built by [One Mipham Corporation](https://onemipham.com) (北京华安麦逄科技有限公司) | Brand: MiphamAI
6
6
 
@@ -15,13 +15,13 @@ Requirements: **Bun 1.2+** (recommended) or **Node.js 22+**
15
15
 
16
16
  ## Features
17
17
 
18
- - **9 AI Providers** — Anthropic Claude · OpenAI GPT · DeepSeek · Kimi (Moonshot) · Google Gemini · Doubao 豆包 · Tencent Hunyuan 混元 · Qwen 通义千问 · MiphamAI
19
- - **66 Slash Commands** — Interactive command picker (↑↓ Enter Esc), intuitive command interface, zero re-learning
20
- - **16 Built-in Tools** — File ops, shell execution, agent dispatch, MCP integration, web search
21
- - **14 Skills + Marketplace** — 11 standard + 3 Mipham-exclusive skills. `/browse-skills` `/install-skill` community marketplace
18
+ - **12 AI Providers** — Anthropic Claude · OpenAI GPT · DeepSeek · Kimi (Moonshot) · Google Gemini · Doubao 豆包 · Tencent Hunyuan 混元 · Qwen 通义千问 · MiniMax(国内 / 国际)· Ollama(本地)· MiphamAI
19
+ - **137 Slash Commands** — Interactive command picker (↑↓ Enter Esc), intuitive command interface, zero re-learning
20
+ - **31 Built-in Tools** — File ops, shell execution, agent dispatch, MCP integration, web search
21
+ - **28 Skills + Marketplace** — 22 standard + 6 Mipham-exclusive skills. `/browse-skills` `/install-skill` community marketplace
22
22
  - **Self-Update** — `mipham update` / `/upgrade` — one command to check, backup config, upgrade, restore. API keys preserved
23
23
  - **Smart Recommendations** — `/recommend` analyzes your project, suggests skills, providers, and config
24
- - **One-Click Config** — `/init` generates config.yml with all 9 providers pre-populated (just replace API keys)
24
+ - **One-Click Config** — `/init` generates config.yml with the providers pre-populated (just replace API keys)
25
25
  - **MCP Protocol** — Full JSON-RPC 2.0 stdio transport for external server integration
26
26
  - **Security Hardened** — Path sandbox · SSRF protection · Bash blacklist · Permission gating · Parameter validation · command injection prevention · API key redaction
27
27
  - **Config Resilience** — YAML error recovery, deep merge providers, auto-backup (5 copies), corruption auto-restore
@@ -34,7 +34,7 @@ Requirements: **Bun 1.2+** (recommended) or **Node.js 22+**
34
34
  mipham
35
35
 
36
36
  # First run auto-initializes config. Or:
37
- /init # Generate ~/.mipham/config.yml with 9 providers pre-configured
37
+ /init # Generate ~/.mipham/config.yml with providers pre-configured
38
38
 
39
39
  # Set API keys (env vars or config.yml)
40
40
  export DEEPSEEK_API_KEY="sk-..."
@@ -73,7 +73,7 @@ Press **Ctrl+P** for model picker · **Shift+Tab** to cycle permission mode · *
73
73
  - [Website (International)](https://mipham.ai/mipham-code)
74
74
  - [国内站](https://onemipham.com/mipham-code)
75
75
  - [GitHub](https://github.com/One-Mipham/mipham-code)
76
- - [Product Specification](https://github.com/One-Mipham/mipham-code/blob/main/PRODUCT.md)
76
+ - [Product Specification (v1.0.0, 2026-06-10 snapshot)](https://github.com/One-Mipham/mipham-code/blob/main/PRODUCT.md) — frozen, not current
77
77
  - [npm](https://www.npmjs.com/package/@miphamai/cli)
78
78
 
79
79
  ## License
package/bin/daemon.ts CHANGED
@@ -1,40 +1,15 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
3
  /**
4
- * Mipham Code Daemon — standalone background process.
5
- * Spawned by `mipham daemon start`.
4
+ * Mipham Code Daemon — standalone source-mode entry.
5
+ *
6
+ * The compiled binary reaches the same code through the hidden `__daemon`
7
+ * branch in bin/mipham.ts. Both call runDaemonProcess() so there is exactly
8
+ * one implementation of the daemon process body.
6
9
  *
7
10
  * Usage: bun run bin/daemon.ts [--port PORT] [--bind HOST]
8
11
  */
9
12
 
10
- import { startDaemon } from '../src/daemon/index'
11
-
12
- const args = process.argv.slice(2)
13
-
14
- // Parse --port and --bind from CLI args (passed by mipham CLI)
15
- for (let i = 0; i < args.length; i++) {
16
- if (args[i] === '--port' && args[i + 1]) {
17
- process.env.MIPHAM_PORT = args[i + 1]!
18
- }
19
- if (args[i] === '--bind' && args[i + 1]) {
20
- process.env.MIPHAM_BIND = args[i + 1]!
21
- }
22
- }
23
-
24
- const { port } = await startDaemon()
25
-
26
- console.log(`Daemon running on http://127.0.0.1:${port}`)
27
- console.log(`PID: ${process.pid}`)
28
-
29
- // Keep process alive
30
- process.on('SIGTERM', async () => {
31
- const { stopDaemon } = await import('../src/daemon/index')
32
- await stopDaemon(true)
33
- process.exit(0)
34
- })
13
+ import { runDaemonProcess } from '../src/daemon/launch'
35
14
 
36
- process.on('SIGINT', async () => {
37
- const { stopDaemon } = await import('../src/daemon/index')
38
- await stopDaemon(true)
39
- process.exit(0)
40
- })
15
+ await runDaemonProcess()
package/bin/mipham.ts CHANGED
@@ -6,6 +6,8 @@
6
6
 
7
7
  export {} // ensure module scope (prevents global name collisions)
8
8
 
9
+ import { DAEMON_ENTRY, userArgs } from '../src/daemon/launch'
10
+
9
11
  async function runWorkflowCLI(): Promise<boolean> {
10
12
  const args = process.argv.slice(2)
11
13
  if (args[0] !== 'workflow') return false
@@ -324,8 +326,6 @@ async function runDaemonCLI(): Promise<boolean> {
324
326
 
325
327
  const subcmd = args[1]
326
328
 
327
- const { spawn } = await import('node:child_process')
328
-
329
329
  if (subcmd === 'start') {
330
330
  const { getDaemonStatus } = await import('../src/daemon/index')
331
331
  const status = getDaemonStatus()
@@ -335,23 +335,13 @@ async function runDaemonCLI(): Promise<boolean> {
335
335
  }
336
336
 
337
337
  console.log('Starting daemon...')
338
- const daemonScript = new URL('./daemon.ts', import.meta.url).pathname
339
- const child = spawn('bun', ['run', daemonScript], {
340
- detached: true,
341
- stdio: 'ignore',
342
- env: { ...process.env },
343
- })
344
- child.unref()
345
-
346
- // Wait briefly for daemon to write PID/port files
347
- await new Promise((resolve) => setTimeout(resolve, 1000))
348
-
349
- const newStatus = getDaemonStatus()
350
- if (newStatus) {
351
- console.log(`Daemon started (PID: ${newStatus.pid}, Port: ${newStatus.port})`)
352
- } else {
353
- console.log('Daemon started (PID unknown — check `mipham daemon status`)')
338
+ const { startDetachedDaemon } = await import('../src/daemon/launch')
339
+ const launch = await startDetachedDaemon()
340
+ if (!launch.ok) {
341
+ console.error(`Failed to start daemon: ${launch.reason}`)
342
+ process.exit(1)
354
343
  }
344
+ console.log(`Daemon started (PID: ${launch.pid}, Port: ${launch.port})`)
355
345
  process.exit(0)
356
346
  }
357
347
 
@@ -387,6 +377,7 @@ async function runDaemonCLI(): Promise<boolean> {
387
377
 
388
378
  if (subcmd === 'restart') {
389
379
  const { getDaemonStatus } = await import('../src/daemon/index')
380
+ const { startDetachedDaemon, waitForDaemonExit } = await import('../src/daemon/launch')
390
381
  const status = getDaemonStatus()
391
382
  if (status) {
392
383
  try {
@@ -394,18 +385,28 @@ async function runDaemonCLI(): Promise<boolean> {
394
385
  } catch {
395
386
  // Process may have already exited
396
387
  }
397
- await new Promise((resolve) => setTimeout(resolve, 500))
388
+ // A fixed sleep here is a guess, and the guess is load-bearing:
389
+ // startDetachedDaemon() opens by probing getStatus(), so a pid file the old
390
+ // daemon has not unlinked yet reads as "already running" — restart would
391
+ // then report success for a start it never performed. Wait for the old
392
+ // daemon to be *gone*; if it never goes, refuse instead of starting blind.
393
+ const waitStarted = Date.now()
394
+ const gone = await waitForDaemonExit()
395
+ if (!gone) {
396
+ const waitedMs = Date.now() - waitStarted
397
+ console.error(
398
+ `Refusing to restart: old daemon (PID: ${status.pid}) is still running after ${waitedMs}ms`,
399
+ )
400
+ process.exit(1)
401
+ }
398
402
  }
399
403
 
400
- const daemonScript = new URL('./daemon.ts', import.meta.url).pathname
401
- const child = spawn('bun', ['run', daemonScript], {
402
- detached: true,
403
- stdio: 'ignore',
404
- env: { ...process.env },
405
- })
406
- child.unref()
407
- await new Promise((resolve) => setTimeout(resolve, 1000))
408
- console.log('Daemon restarted.')
404
+ const launch = await startDetachedDaemon()
405
+ if (!launch.ok) {
406
+ console.error(`Failed to restart daemon: ${launch.reason}`)
407
+ process.exit(1)
408
+ }
409
+ console.log(`Daemon restarted (PID: ${launch.pid}, Port: ${launch.port})`)
409
410
  process.exit(0)
410
411
  }
411
412
 
@@ -1095,6 +1096,19 @@ async function runInitCLI(): Promise<boolean> {
1095
1096
  }
1096
1097
 
1097
1098
  async function main() {
1099
+ // ── Hidden daemon entry ────────────────────────────────────────────────
1100
+ // `daemon start` re-execs this same program with `__daemon` (see
1101
+ // src/daemon/launch.ts). This must come first: everything below assumes an
1102
+ // interactive TTY — the stty block would run against a detached child whose
1103
+ // stdio is the daemon log. The sentinel is stripped here because the two
1104
+ // entry points have different argv shapes (see runDaemonProcess).
1105
+ const argv = userArgs(process.argv)
1106
+ if (argv[0] === DAEMON_ENTRY) {
1107
+ const { runDaemonProcess } = await import('../src/daemon/launch')
1108
+ await runDaemonProcess(argv.slice(1))
1109
+ return
1110
+ }
1111
+
1098
1112
  // ── Deleted-cwd guard ──────────────────────────────────────────────────
1099
1113
  // `process.cwd()` throws ENOENT when the directory the process was launched
1100
1114
  // from no longer exists (e.g. a removed git worktree). Print a clear message
@@ -1309,4 +1323,4 @@ Docs: https://onemipham.com/mipham-code
1309
1323
  }
1310
1324
  }
1311
1325
 
1312
- main()
1326
+ void main()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.81.6",
3
+ "version": "0.81.8",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -40,7 +40,8 @@
40
40
  "typecheck": "tsc --noEmit",
41
41
  "test": "vitest run",
42
42
  "knip": "knip --production --no-progress --no-exit-code",
43
- "coverage": "vitest run --coverage --coverage.provider=v8 --coverage.include='src/**/*.{ts,tsx}' --coverage.reporter=text-summary --coverage.reporter=json-summary"
43
+ "coverage": "vitest run --coverage",
44
+ "mutate": "stryker run"
44
45
  },
45
46
  "dependencies": {
46
47
  "@larksuiteoapi/node-sdk": "^1.73.0",
@@ -53,6 +54,8 @@
53
54
  },
54
55
  "devDependencies": {
55
56
  "@mipham/shared": "workspace:*",
57
+ "@stryker-mutator/core": "10.0.0",
58
+ "@stryker-mutator/vitest-runner": "10.0.0",
56
59
  "@types/bun": "^1.4.2",
57
60
  "@types/node": "^22.19.19",
58
61
  "@types/react": "^19.3.0",
@@ -286,10 +286,10 @@ providers:
286
286
 
287
287
  ### 5.1 — Built-in Skills
288
288
 
289
- Mipham Code ships with 17 built-in skills loaded automatically:
289
+ Mipham Code ships with 28 built-in skills loaded automatically:
290
290
 
291
- - **Standard (14)**: code-review, compassionate-communication, doc-generator, github-ops, memory, mipham-code-setup, security-review, self-review, superpower, systematic-debugging, tdd, test-driven-development, web-access, web-search
292
- - **Mipham (3)**: om-artifact, om-model-optimize, om-security
291
+ - **Standard (22)**: code-review, codebase-design, compassionate-communication, debug-loop, doc-generator, domain-modeling, github-ops, grill-with-docs, implement, memory, mipham-code-setup, research, safe-coding, security-review, self-review, superpower, tdd, to-spec, triage, trim-process-prose, web-access, web-search
292
+ - **Mipham (6)**: doc-sync, om-artifact, om-model-optimize, om-security, save-to-wiki, self-audit
293
293
 
294
294
  ### 5.2 — Community Skills
295
295
 
@@ -10,6 +10,7 @@ import type { PermissionSystem } from '../core/permission'
10
10
  import { AgentExperience } from './agent-experience'
11
11
  import { PatternAnalyzer } from './pattern-analyzer.js'
12
12
  import { getWorkspaceTrust } from '../core/workspace-trust'
13
+ import { recordToolCall } from '../telemetry/index'
13
14
  import type { ExperienceRuleEngine } from '../core/rule-engine.js'
14
15
 
15
16
  // Singleton instances (created lazily)
@@ -85,7 +86,7 @@ export class SubAgent {
85
86
  // Register completion callback for hook firing
86
87
  bgRegistry.onComplete(taskId, (task) => {
87
88
  if (this.hookEngine) {
88
- this.hookEngine.executeSubagentStop(
89
+ void this.hookEngine.executeSubagentStop(
89
90
  agentType,
90
91
  description,
91
92
  taskId,
@@ -397,6 +398,15 @@ export class SubAgent {
397
398
  throw new DOMException('Aborted', 'AbortError')
398
399
  }
399
400
 
401
+ // Counted here, at dispatch, mirroring `Engine.executeTool`'s entry
402
+ // increment — this loop bypasses `executeTool` entirely and
403
+ // reimplements the hook/permission steps itself, so without this line
404
+ // every tool call made inside a sub-agent (and therefore inside a
405
+ // workflow, which derives sub-agents) is invisible to the metrics
406
+ // registry. Same failure shape as the two "one of two paths wired"
407
+ // bugs this repo has already recorded.
408
+ recordToolCall(tu.name)
409
+
400
410
  const tool = this.toolRegistry.get(tu.name)
401
411
  if (!tool) {
402
412
  currentMessages.push({
@@ -490,6 +500,7 @@ export class SubAgent {
490
500
  content: displayResult.success
491
501
  ? displayResult.content
492
502
  : displayResult.error || displayResult.content,
503
+ ...(displayResult.success ? {} : { is_error: true }),
493
504
  },
494
505
  ],
495
506
  })
@@ -1,47 +1,95 @@
1
- import {
2
- readFileSync,
3
- writeFileSync,
4
- existsSync,
5
- mkdirSync,
6
- renameSync,
7
- copyFileSync,
8
- unlinkSync,
9
- } from 'node:fs'
1
+ import { readFileSync, existsSync, mkdirSync, renameSync, copyFileSync, unlinkSync } from 'node:fs'
10
2
  import { join } from 'node:path'
3
+ import { atomicWriteFileSync } from '../shared/atomic-write'
11
4
  import type { ArtifactManifest, ArtifactEntry } from '../shared/types'
12
5
 
6
+ function emptyManifest(): ArtifactManifest {
7
+ return { version: 1, artifacts: [] }
8
+ }
9
+
13
10
  /**
14
11
  * Read the artifact manifest from disk, or return an empty one if it doesn't exist.
12
+ *
13
+ * An unreadable index also yields an empty manifest — the callers here only *show*
14
+ * artifacts (gallery, `/artifact list`), and showing none beats throwing at them.
15
+ * Writers must not go through this path: see `readManifestForUpdate`.
15
16
  */
16
17
  export function readManifest(dir: string): ArtifactManifest {
17
18
  const path = join(dir, 'index.json')
18
19
  if (!existsSync(path)) {
19
- return { version: 1, artifacts: [] }
20
+ return emptyManifest()
20
21
  }
21
22
  try {
22
23
  return JSON.parse(readFileSync(path, 'utf-8'))
23
24
  } catch {
24
- return { version: 1, artifacts: [] }
25
+ return emptyManifest()
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Read the manifest for a caller that is about to write it back.
31
+ *
32
+ * The difference from `readManifest` is what happens when the file exists but does
33
+ * not parse: a writer must not treat that as "empty", because writing back an
34
+ * empty manifest replaces every other artifact's entry with nothing — the files
35
+ * stay on disk, but the gallery and `/artifact list` lose them. So the unreadable
36
+ * file is renamed aside (bytes kept, recoverable by hand) and reported, and the
37
+ * caller decides what to tell the user.
38
+ */
39
+ function readManifestForUpdate(dir: string): {
40
+ manifest: ArtifactManifest
41
+ quarantined?: string
42
+ } {
43
+ const path = join(dir, 'index.json')
44
+ if (!existsSync(path)) return { manifest: emptyManifest() }
45
+ try {
46
+ return { manifest: JSON.parse(readFileSync(path, 'utf-8')) }
47
+ } catch {
48
+ const quarantined = `${path}.corrupt-${new Date().toISOString().replace(/[:.]/g, '-')}`
49
+ // If this rename fails we let it throw: the alternative is overwriting the
50
+ // only copy of the index with a manifest built from nothing.
51
+ renameSync(path, quarantined)
52
+ return { manifest: emptyManifest(), quarantined }
25
53
  }
26
54
  }
27
55
 
28
56
  /**
29
57
  * Write the manifest to disk, creating parent directories as needed.
58
+ *
59
+ * Atomic: a crash mid-write used to leave a truncated `index.json`, which is
60
+ * exactly the corruption `readManifestForUpdate` then has to quarantine.
30
61
  */
31
62
  export function writeManifest(dir: string, manifest: ArtifactManifest): void {
32
63
  mkdirSync(dir, { recursive: true })
33
- writeFileSync(join(dir, 'index.json'), JSON.stringify(manifest, null, 2), 'utf-8')
64
+ atomicWriteFileSync(join(dir, 'index.json'), JSON.stringify(manifest, null, 2), {
65
+ mode: 0o644,
66
+ })
34
67
  }
35
68
 
36
69
  /**
37
70
  * Add an entry to the manifest and persist it.
38
- * If an entry with the same name already exists, it is replaced.
71
+ *
72
+ * An entry is identified by `name` **and** `sessionId`, matching how the tool
73
+ * looks one up: the manifest is a single global `index.json` holding every
74
+ * session's artifacts, so keying on the name alone made two sessions publishing
75
+ * the same name overwrite each other's entry — the loser's file stayed on disk
76
+ * but vanished from the index.
77
+ *
78
+ * Returns the written manifest plus, when the previous index was unreadable, the
79
+ * path its bytes were moved to — the caller is expected to say so rather than
80
+ * let an artifact appear to publish cleanly over a lost index.
39
81
  */
40
- export function addToManifest(dir: string, entry: ArtifactEntry, port?: number): ArtifactManifest {
41
- const manifest = readManifest(dir)
82
+ export function addToManifest(
83
+ dir: string,
84
+ entry: ArtifactEntry,
85
+ port?: number,
86
+ ): { manifest: ArtifactManifest; quarantined?: string } {
87
+ const { manifest, quarantined } = readManifestForUpdate(dir)
42
88
  if (port !== undefined) manifest.port = port
43
89
 
44
- const idx = manifest.artifacts.findIndex((a) => a.name === entry.name)
90
+ const idx = manifest.artifacts.findIndex(
91
+ (a) => a.name === entry.name && a.sessionId === entry.sessionId,
92
+ )
45
93
  if (idx >= 0) {
46
94
  manifest.artifacts[idx] = entry
47
95
  } else {
@@ -49,7 +97,7 @@ export function addToManifest(dir: string, entry: ArtifactEntry, port?: number):
49
97
  }
50
98
 
51
99
  writeManifest(dir, manifest)
52
- return manifest
100
+ return { manifest, quarantined }
53
101
  }
54
102
 
55
103
  /**
@@ -64,31 +112,39 @@ export function getSessionArtifacts(dir: string, sessionId: string): ArtifactEnt
64
112
  * Archive an existing artifact file by renaming it with a version tag.
65
113
  * e.g. dashboard.html → dashboard.v1.html, dashboard.v1.html → dashboard.v2.html.
66
114
  *
67
- * Returns the version tag assigned to the archived file.
115
+ * Returns the version tag assigned to the archived file, or `undefined` when
116
+ * there was nothing to archive — in which case the manifest is left untouched.
117
+ *
118
+ * 「没归档就什么都不记」是刻意的:源文件找不到时照样推进版本号、往 `versions` 里
119
+ * 塞一个标签,等于在 manifest 里留一版磁盘上并不存在的版本。走到那条路并不难
120
+ * —— 条目按 name 找、文件按 `<session>/<name><ext>` 找,把同一个名字从 html 改
121
+ * 成 svg 就错开了。宁可少记一版,也不能记一版假的。
68
122
  */
69
- export function archiveVersion(dir: string, entry: ArtifactEntry): string {
70
- const manifest = readManifest(dir)
71
- const versionCount = (entry.versionCount || 1) + 1
123
+ export function archiveVersion(dir: string, entry: ArtifactEntry): string | undefined {
72
124
  const ext = entry.type === 'svg' ? '.svg' : '.html'
73
- const versionTag = `v${versionCount}`
74
-
75
- // Rename the current file to a versioned copy
76
125
  const baseName = entry.name
77
126
  const currentPath = join(dir, entry.sessionId, `${baseName}${ext}`)
127
+
128
+ if (!existsSync(currentPath)) return undefined
129
+
130
+ const versionCount = (entry.versionCount || 1) + 1
131
+ const versionTag = `v${versionCount}`
78
132
  const archivedPath = join(dir, entry.sessionId, `${baseName}.${versionTag}${ext}`)
79
133
 
80
- if (existsSync(currentPath)) {
81
- try {
82
- renameSync(currentPath, archivedPath)
83
- } catch {
84
- // If rename fails (e.g. cross-device), copy instead
85
- copyFileSync(currentPath, archivedPath)
86
- unlinkSync(currentPath)
87
- }
134
+ try {
135
+ renameSync(currentPath, archivedPath)
136
+ } catch {
137
+ // If rename fails (e.g. cross-device), copy instead
138
+ copyFileSync(currentPath, archivedPath)
139
+ unlinkSync(currentPath)
88
140
  }
89
141
 
90
- // Update manifest entry
91
- const artifact = manifest.artifacts.find((a) => a.name === entry.name)
142
+ // Update manifest entry — keyed on name *and* session, like every other
143
+ // lookup here: a same-named artifact in another session is a different one.
144
+ const manifest = readManifest(dir)
145
+ const artifact = manifest.artifacts.find(
146
+ (a) => a.name === entry.name && a.sessionId === entry.sessionId,
147
+ )
92
148
  if (artifact) {
93
149
  const versions = artifact.versions || ['v1']
94
150
  versions.push(versionTag)
@@ -0,0 +1,19 @@
1
+ import { join } from 'node:path'
2
+ import { ARTIFACTS_DIR, MIPHAM_DIR } from '../shared/constants'
3
+
4
+ /**
5
+ * The one place that decides *where on disk an artifact lives*.
6
+ *
7
+ * Three sites need to agree on this: the Artifact tool (writes the file and the
8
+ * manifest), the ArtifactServer (serves files and builds the gallery from the
9
+ * manifest rooted here), and `/artifact list` (reads the manifest). Each of them
10
+ * computing the path itself is what produced the original bug: the tool wrote
11
+ * `<cwd>/artifacts/...` while the server served `<cwd>/.mipham/artifacts/...`,
12
+ * so the URL the tool reported was a guaranteed 404 — the two halves never met.
13
+ *
14
+ * Keeping them joined here means a change moves all three together; the guard
15
+ * test asserts the *behaviour* (the reported URL resolves), not the literal.
16
+ */
17
+ export function artifactsRoot(cwd: string): string {
18
+ return join(cwd, MIPHAM_DIR, ARTIFACTS_DIR)
19
+ }
@@ -1,9 +1,9 @@
1
1
  import { createServer, type Server } from 'node:http'
2
- import { createReadStream, existsSync, statSync } from 'node:fs'
2
+ import type { Socket } from 'node:net'
3
+ import { createReadStream, existsSync, readFileSync, statSync } from 'node:fs'
3
4
  import { join, normalize, extname } from 'node:path'
4
5
  import { ARTIFACT_ALLOWED_EXTENSIONS } from '../shared/constants'
5
6
  import { readManifest } from './manifest'
6
- import { ArtifactVersioning } from './versioning'
7
7
  import type { ArtifactEntry } from '../shared/types'
8
8
  import { getMetrics } from '../core/metrics'
9
9
 
@@ -29,12 +29,11 @@ export class ArtifactServer {
29
29
  private started = false
30
30
  private sseClients: SseClient[] = []
31
31
  private sseIdCounter = 0
32
- private versioning: ArtifactVersioning
32
+ private sockets = new Set<Socket>()
33
33
 
34
34
  constructor(artifactsDir: string, preferredPort: number) {
35
35
  this.artifactsDir = artifactsDir
36
36
  this.port = preferredPort
37
- this.versioning = new ArtifactVersioning(artifactsDir)
38
37
  }
39
38
 
40
39
  /** Notify all connected SSE clients to reload. Called after artifact changes. */
@@ -82,6 +81,12 @@ export class ArtifactServer {
82
81
  this.server = null
83
82
  this.started = false
84
83
  }
84
+
85
+ // `close()` only stops *accepting* — sockets already established (a browser's
86
+ // keep-alive, an SSE stream) stay open and keep being served, so a "stopped"
87
+ // server answers on the old port until the client hangs up. Destroy them.
88
+ for (const socket of this.sockets) socket.destroy()
89
+ this.sockets.clear()
85
90
  }
86
91
 
87
92
  getPort(): number {
@@ -114,6 +119,10 @@ export class ArtifactServer {
114
119
  const srv = createServer((req, res) => {
115
120
  this.handleRequest(req, res)
116
121
  })
122
+ srv.on('connection', (socket) => {
123
+ this.sockets.add(socket)
124
+ socket.on('close', () => this.sockets.delete(socket))
125
+ })
117
126
  srv.on('error', reject)
118
127
  srv.listen(port, () => {
119
128
  this.server = srv
@@ -216,8 +225,31 @@ export class ArtifactServer {
216
225
  })
217
226
  }
218
227
 
219
- /** Per-artifact SSE stream: pushes content updates to connected browsers every 500ms. */
228
+ /**
229
+ * Per-artifact SSE stream: pushes the artifact's content to connected browsers
230
+ * every 500ms, so a page can follow an artifact the AI rewrites in place.
231
+ *
232
+ * Resolves the file through `resolveFile` — the same coordinate the gallery
233
+ * links to and the same traversal guard the static path uses — and 404s when no
234
+ * artifact carries that name, instead of holding a stream open on a file that
235
+ * does not exist.
236
+ */
220
237
  private handleNameSse(name: string, res: any): void {
238
+ const entry = readManifest(this.artifactsDir).artifacts.find((a) => a.name === name)
239
+ if (!entry) {
240
+ res.writeHead(404, { 'Content-Type': 'text/plain' })
241
+ res.end(`No artifact named "${name}"`)
242
+ return
243
+ }
244
+
245
+ const ext = entry.type === 'svg' ? '.svg' : '.html'
246
+ const { filePath, error, status } = this.resolveFile(`/${entry.sessionId}/${entry.name}${ext}`)
247
+ if (error) {
248
+ res.writeHead(status || 404, { 'Content-Type': 'text/plain' })
249
+ res.end(error)
250
+ return
251
+ }
252
+
221
253
  res.writeHead(200, {
222
254
  'Content-Type': 'text/event-stream',
223
255
  'Cache-Control': 'no-cache',
@@ -225,10 +257,18 @@ export class ArtifactServer {
225
257
  'Access-Control-Allow-Origin': '*',
226
258
  })
227
259
 
260
+ let last: string | null = null
228
261
  const interval = setInterval(() => {
229
- const content = this.versioning.getVersion(name)
230
- if (content) {
231
- res.write(`data: ${JSON.stringify({ type: 'update', name, content })}\n\n`)
262
+ try {
263
+ const content = readFileSync(filePath, 'utf-8')
264
+ // Only on change: without this the stream re-sends the whole file every
265
+ // 500ms per client for as long as the tab stays open.
266
+ if (content !== last) {
267
+ last = content
268
+ res.write(`data: ${JSON.stringify({ type: 'update', name, content })}\n\n`)
269
+ }
270
+ } catch {
271
+ // Momentarily absent (archiving renames it) — skip this tick, try the next.
232
272
  }
233
273
  }, 500)
234
274