@miphamai/cli 0.85.1 → 0.85.3

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 (63) hide show
  1. package/bin/mipham.ts +42 -2
  2. package/package.json +1 -1
  3. package/skills/standard/mipham-code-setup.SKILL.md +9 -2
  4. package/src/agent/agent-experience.ts +3 -2
  5. package/src/agent/background-registry.ts +7 -3
  6. package/src/agent/cross-session/discovery.ts +5 -12
  7. package/src/agent/cross-session/file-inbox.ts +2 -2
  8. package/src/agent/effectiveness-tracker.ts +3 -2
  9. package/src/agent/sub-agent.ts +67 -6
  10. package/src/agent/types.ts +10 -0
  11. package/src/agent-view/agent-view-manager.ts +46 -0
  12. package/src/agent-view/dashboard.tsx +106 -16
  13. package/src/agent-view/session-view.tsx +128 -0
  14. package/src/commands/autoloop-journal.ts +6 -5
  15. package/src/commands/environment.ts +11 -8
  16. package/src/commands/project.ts +5 -3
  17. package/src/config/credential-crypto.ts +13 -1
  18. package/src/config/keys-manager.ts +7 -4
  19. package/src/config/loader.ts +144 -41
  20. package/src/config/preferences.ts +6 -3
  21. package/src/core/constitution-loader.ts +3 -2
  22. package/src/core/context.ts +35 -6
  23. package/src/core/crsi-producer.ts +4 -2
  24. package/src/core/crsi-sandbox.ts +2 -1
  25. package/src/core/dream-engine.ts +5 -11
  26. package/src/core/engine.ts +9 -4
  27. package/src/core/error-signature-db.ts +3 -2
  28. package/src/core/eval-harness.ts +3 -3
  29. package/src/core/hooks-executor.ts +60 -2
  30. package/src/core/instructions.ts +69 -48
  31. package/src/core/memory/memory-manager.ts +10 -6
  32. package/src/core/permission-audit.ts +13 -6
  33. package/src/core/permission-config.ts +28 -7
  34. package/src/core/permission-rules.ts +99 -5
  35. package/src/core/permission.ts +6 -1
  36. package/src/core/rule-engine.ts +3 -2
  37. package/src/core/session-log.ts +48 -11
  38. package/src/core/session-store.ts +64 -44
  39. package/src/daemon/attach-protocol.ts +30 -3
  40. package/src/daemon/auth.ts +4 -3
  41. package/src/daemon/index.ts +4 -3
  42. package/src/daemon/remote-engine.ts +173 -24
  43. package/src/daemon/server.ts +51 -1
  44. package/src/daemon/session-worker.ts +34 -1
  45. package/src/i18n-core/locales/en-US.json +1 -0
  46. package/src/i18n-core/locales/zh-CN.json +1 -0
  47. package/src/index.tsx +75 -17
  48. package/src/mcp/oauth.ts +47 -5
  49. package/src/mcp/token-store.ts +10 -11
  50. package/src/providers/openai-compat.ts +11 -0
  51. package/src/shared/arg-validation.ts +74 -2
  52. package/src/shared/package-info.ts +1 -1
  53. package/src/shared/regular-file.ts +63 -0
  54. package/src/shared/sanitize.ts +14 -2
  55. package/src/skills/bundled-skills.ts +1 -1
  56. package/src/tools/agent/memory.ts +4 -2
  57. package/src/tools/agent/workflow.ts +6 -3
  58. package/src/tools/exec/task.ts +82 -30
  59. package/src/tools/scheduling/cron.ts +3 -9
  60. package/src/ui/app.tsx +78 -14
  61. package/src/ui/commands.ts +73 -31
  62. package/src/ui/ctrl-c-confirm.ts +63 -0
  63. package/src/workflow/journal.ts +80 -26
@@ -1,8 +1,9 @@
1
- import { appendFileSync, readFileSync, mkdirSync, existsSync } from 'node:fs'
1
+ import { mkdirSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import { createHash } from 'node:crypto'
4
4
  import type { Message, ToolUseContent, ToolResultContent, ToolResult } from '../shared/types'
5
5
  import type { CheckerDecision } from './post-flight-checker'
6
+ import { appendRegularFileSync, readRegularFileSync } from '../shared/regular-file'
6
7
  import { miphamHome } from './paths.ts'
7
8
 
8
9
  export type SessionEvent =
@@ -117,6 +118,9 @@ export function deriveMessages(events: SessionEvent[]): Message[] {
117
118
 
118
119
  const LOG_DIR = miphamHome('sessions')
119
120
 
121
+ /** 一次性告警:日志路径不是普通文件(写不进去),每个进程只说一句。 */
122
+ let warnedNotAppendable = false
123
+
120
124
  /** 将会话名消毒为安全文件名(与 SessionStore 共用;防路径穿越)。 */
121
125
  export function sanitizeSessionName(name: string): string {
122
126
  const safe = name.replace(/[^a-zA-Z0-9_-]/g, '_')
@@ -147,22 +151,34 @@ export class SessionLog {
147
151
  // Session logs contain full conversation + tool results (possibly credentials):
148
152
  // restrict to owner-only (dir 0700, file 0600).
149
153
  mkdirSync(LOG_DIR, { recursive: true, mode: 0o700 })
154
+ const path = join(LOG_DIR, `${sanitizeSessionName(this.name)}.jsonl`)
155
+ let wrote = 0
150
156
  for (const e of this.buf.slice(this.flushed)) {
151
- appendFileSync(
152
- join(LOG_DIR, `${sanitizeSessionName(this.name)}.jsonl`),
153
- JSON.stringify(e) + '\n',
154
- { encoding: 'utf-8', mode: 0o600 },
155
- )
157
+ // FIFO/socket 路径上「写不进去」而不是**干等**(`appendFileSync` 会打开目标写)。
158
+ // 写失败时**不推进** flushed:这些事件还算未落盘,路径恢复后下一次 save 会补上。
159
+ if (!appendRegularFileSync(path, JSON.stringify(e) + '\n', { mode: 0o600 })) {
160
+ if (!warnedNotAppendable) {
161
+ warnedNotAppendable = true
162
+ process.stderr.write(
163
+ `⚠ Mipham Code: ${path} is not a regular file — session log events were not written.\n`,
164
+ )
165
+ }
166
+ break
167
+ }
168
+ wrote++
156
169
  }
157
- this.flushed = this.buf.length
170
+ this.flushed += wrote
158
171
  }
159
172
 
160
173
  /** 从既有 JSONL 打开,逐行解析为事件(已落盘事件标记为已 flush)。 */
161
174
  static open(name: string): SessionLog {
162
175
  const log = new SessionLog(name)
163
176
  const path = join(LOG_DIR, `${sanitizeSessionName(name)}.jsonl`)
164
- if (!existsSync(path)) return log
165
- for (const line of readFileSync(path, 'utf-8').split('\n')) {
177
+ // 类型闸在读取之前:`SessionStore.load` 在快照读不出来时**回落到这里**,所以路径上
178
+ // 是个 FIFO 时不能在这里换个函数继续等 —— 读不动就当空日志(见 shared/regular-file.ts)。
179
+ const raw = readRegularFileSync(path)
180
+ if (raw === null) return log
181
+ for (const line of raw.split('\n')) {
166
182
  const trimmed = line.trim()
167
183
  if (!trimmed) continue
168
184
  try {
@@ -178,6 +194,23 @@ export class SessionLog {
178
194
  }
179
195
  }
180
196
 
197
+ /**
198
+ * 消息形状闸 —— 投影与**出网**两侧都会解引用的字段的最低门槛,不是 schema 校验。
199
+ *
200
+ * 从前这里只查「`message` 是个对象」,于是 `{"type":"user/message","message":{}}`
201
+ * 一路活到线上:投影照原样收下它,provider 侧读到 `content` 为 `undefined` 走
202
+ * 「非字符串即 `ContentBlock[]`」那条分支,`.filter` 当场 TypeError(实测)。
203
+ * `content` 数组里的块同理 —— provider 会 `b.type` 逐个读。
204
+ */
205
+ export function isValidMessage(m: unknown): boolean {
206
+ if (!m || typeof m !== 'object') return false
207
+ const msg = m as Record<string, unknown>
208
+ if (msg.role !== 'user' && msg.role !== 'assistant' && msg.role !== 'system') return false
209
+ const content = msg.content
210
+ if (typeof content === 'string') return true
211
+ return Array.isArray(content) && content.every((b) => !!b && typeof b === 'object')
212
+ }
213
+
181
214
  /**
182
215
  * 事件结构校验 —— 磁盘→内存的**唯一**入口(`open()`)用它挡掉坏行。
183
216
  *
@@ -187,6 +220,9 @@ export class SessionLog {
187
220
  * 却让投影抛 TypeError(rewrite 那条更狠:`out` 直接变 `undefined`,下一条就炸),
188
221
  * 而 `/resume` 那条链上没有 try 兜它。坏行来自手写/拼接/半截重排,不是本进程写的。
189
222
  *
223
+ * 带 message 的事件还要过 `isValidMessage`:投影只把它 `push` 进数组、自己不碰字段,
224
+ * 所以「投影不许崩」在这里**不够** —— 崩点在下游(provider 分支、UI 的 `msg.role`)。
225
+ *
190
226
  * 不校验 `session/start`、`assistant/chunk`、`checker/decision`:投影对它们无分支;
191
227
  * 未知类型一律放行(前向兼容 —— 认不出来不等于要销毁它)。
192
228
  */
@@ -197,7 +233,7 @@ function isValidEvent(e: unknown): e is SessionEvent {
197
233
  switch (ev.type) {
198
234
  case 'user/message':
199
235
  case 'assistant/message':
200
- return !!ev.message && typeof ev.message === 'object'
236
+ return isValidMessage(ev.message)
201
237
  case 'tool/call':
202
238
  return (
203
239
  typeof ev.id === 'string' &&
@@ -213,7 +249,8 @@ function isValidEvent(e: unknown): e is SessionEvent {
213
249
  case 'compaction/summary':
214
250
  return typeof ev.summary === 'string'
215
251
  case 'compaction/rewrite':
216
- return Array.isArray(ev.messages)
252
+ // 快照替换:整份投影由它重建 ⇒ 元素形状与 message 事件同罪
253
+ return Array.isArray(ev.messages) && ev.messages.every(isValidMessage)
217
254
  default:
218
255
  return true
219
256
  }
@@ -1,17 +1,16 @@
1
- import {
2
- mkdirSync,
3
- readdirSync,
4
- readFileSync,
5
- writeFileSync,
6
- unlinkSync,
7
- renameSync,
8
- existsSync,
9
- statSync,
10
- } from 'node:fs'
1
+ import { mkdirSync, readdirSync, unlinkSync, existsSync, statSync } from 'node:fs'
11
2
  import { join } from 'node:path'
12
3
  import { createHash } from 'node:crypto'
13
4
  import type { Message } from '../shared/types'
14
- import { sanitizeSessionName, SessionLog, deriveMessages, messageToEvents } from './session-log'
5
+ import {
6
+ sanitizeSessionName,
7
+ SessionLog,
8
+ deriveMessages,
9
+ messageToEvents,
10
+ isValidMessage,
11
+ } from './session-log'
12
+ import { atomicWriteFileSync } from '../shared/atomic-write'
13
+ import { readRegularFileSync } from '../shared/regular-file'
15
14
  import { miphamHome } from './paths.ts'
16
15
 
17
16
  export interface SessionMetadata {
@@ -54,6 +53,25 @@ function filePath(name: string): string {
54
53
  return join(SESSIONS_DIR, `${sanitizeSessionName(name)}.jsonl`)
55
54
  }
56
55
 
56
+ /**
57
+ * 旧格式快照(单 JSON 对象 `{metadata, messages}`)的形状闸。
58
+ *
59
+ * `'metadata' in parsed` 拦不住 `{"metadata": null, …}` —— `in` 看的是**键在不在**,
60
+ * 而 `/resume <name>` 紧接着就读 `session.metadata.name`,那条链上没有 try(实测
61
+ * `TypeError`)。`messages` 的元素同理:`[null]` 会一路送到 UI 的
62
+ * `forwardedMessages.map((msg) => msg.role)`(`app.tsx:1041`)与 provider 的内容分支。
63
+ *
64
+ * 形状不对**整份返回 null**,而不是把坏元素过滤掉:调用方随即回落到事件日志那条路,
65
+ * 读不出来会如实报 load failed;过滤则会凭空造出一段残缺历史 —— 静默改写用户数据。
66
+ */
67
+ function asStoredSnapshot(parsed: unknown): StoredSession | null {
68
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
69
+ const { metadata, messages } = parsed as { metadata?: unknown; messages?: unknown }
70
+ if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) return null
71
+ if (!Array.isArray(messages) || !messages.every(isValidMessage)) return null
72
+ return { metadata: metadata as SessionMetadata, messages: messages as Message[] }
73
+ }
74
+
57
75
  export class SessionStore {
58
76
  /**
59
77
  * Save a session as JSONL (one JSON object per line).
@@ -79,10 +97,11 @@ export class SessionStore {
79
97
  messages,
80
98
  }
81
99
 
82
- // Atomic write: write to temp file, then rename (same-fs rename is atomic)
83
- const tmp = path + '.tmp'
84
- writeFileSync(tmp, JSON.stringify(session) + '\n', { encoding: 'utf-8', mode: 0o600 })
85
- renameSync(tmp, path)
100
+ // Atomic write: write to temp file, then rename (same-fs rename is atomic).
101
+ // 临时名由 atomicWriteFileSync 生成(pid + 随机后缀)—— 从前这里是固定的
102
+ // `path + '.tmp'`,两个进程同时存同一个会话时,先 rename 的那个会把**后一个**写者的
103
+ // 内容搬进目标,而它自己的 rename 再抛 ENOENT:在一个「目标永不半截」的函数里丢一次写。
104
+ atomicWriteFileSync(path, JSON.stringify(session) + '\n', { mode: 0o600 })
86
105
 
87
106
  // Incremental index update — only touch this session's entry
88
107
  try {
@@ -123,18 +142,12 @@ export class SessionStore {
123
142
  /** 从磁盘重开一个 SessionLog(不存在返回空 log)。旧格式快照自动迁移为事件日志。 */
124
143
  static loadLog(name: string): SessionLog {
125
144
  const path = filePath(name)
126
- if (!existsSync(path)) return SessionLog.open(name)
127
145
  try {
128
- const raw = readFileSync(path, 'utf-8')
129
- const parsed = JSON.parse(raw) as unknown
130
- if (
131
- parsed &&
132
- typeof parsed === 'object' &&
133
- 'metadata' in parsed &&
134
- Array.isArray((parsed as { messages?: unknown }).messages)
135
- ) {
146
+ const raw = readRegularFileSync(path)
147
+ if (raw === null) return SessionLog.open(name)
148
+ const old = asStoredSnapshot(JSON.parse(raw))
149
+ if (old) {
136
150
  // 旧格式快照 → 迁移为事件日志(重写文件,避免后续 append 混格式)
137
- const old = parsed as StoredSession
138
151
  const log = new SessionLog(name)
139
152
  log.append({
140
153
  type: 'session/start',
@@ -160,19 +173,12 @@ export class SessionStore {
160
173
  */
161
174
  static load(name: string): StoredSession | null {
162
175
  const path = filePath(name)
163
- if (!existsSync(path)) return null
164
-
165
176
  try {
166
- const raw = readFileSync(path, 'utf-8')
167
- // 旧格式:单 JSON 对象 {metadata, messages}
168
- const parsed = JSON.parse(raw) as unknown
169
- if (
170
- parsed &&
171
- typeof parsed === 'object' &&
172
- 'metadata' in parsed &&
173
- Array.isArray((parsed as { messages?: unknown }).messages)
174
- ) {
175
- return parsed as StoredSession
177
+ const raw = readRegularFileSync(path)
178
+ if (raw !== null) {
179
+ // 旧格式:单 JSON 对象 {metadata, messages}
180
+ const old = asStoredSnapshot(JSON.parse(raw))
181
+ if (old) return old
176
182
  }
177
183
  } catch {
178
184
  // 多行 JSONL → 新格式,走事件解析
@@ -295,7 +301,7 @@ export class SessionStore {
295
301
  }
296
302
  }
297
303
 
298
- writeFileSync(INDEX_FILE, JSON.stringify(index, null, 2), { encoding: 'utf-8', mode: 0o600 })
304
+ atomicWriteFileSync(INDEX_FILE, JSON.stringify(index, null, 2), { mode: 0o600 })
299
305
  }
300
306
 
301
307
  /**
@@ -323,7 +329,7 @@ export class SessionStore {
323
329
  } else {
324
330
  index.push(entry)
325
331
  }
326
- writeFileSync(INDEX_FILE, JSON.stringify(index, null, 2), { encoding: 'utf-8', mode: 0o600 })
332
+ atomicWriteFileSync(INDEX_FILE, JSON.stringify(index, null, 2), { mode: 0o600 })
327
333
  }
328
334
 
329
335
  /**
@@ -340,8 +346,7 @@ export class SessionStore {
340
346
  ? `${safeRaw.slice(0, 80)}-${createHash('sha256').update(safeRaw).digest('hex').slice(0, 16)}`
341
347
  : safeRaw
342
348
  const summaryPath = join(SUMMARIES_DIR, `${safe}.md`)
343
- writeFileSync(summaryPath, `# ${name}\n\n${summary}\n\nTags: ${tags.join(', ')}\n`, {
344
- encoding: 'utf-8',
349
+ atomicWriteFileSync(summaryPath, `# ${name}\n\n${summary}\n\nTags: ${tags.join(', ')}\n`, {
345
350
  mode: 0o600,
346
351
  })
347
352
 
@@ -365,7 +370,7 @@ export class SessionStore {
365
370
  tags,
366
371
  })
367
372
  }
368
- writeFileSync(INDEX_FILE, JSON.stringify(index, null, 2), { encoding: 'utf-8', mode: 0o600 })
373
+ atomicWriteFileSync(INDEX_FILE, JSON.stringify(index, null, 2), { mode: 0o600 })
369
374
  } catch {
370
375
  // Index write is best-effort
371
376
  }
@@ -414,11 +419,26 @@ export class SessionStore {
414
419
 
415
420
  /**
416
421
  * Read the raw index file, returning empty array if missing or corrupt.
422
+ *
423
+ * 「corrupt」不只是语法错:`null`、`{"a":1}`、`[1,2]` 都过得了 `JSON.parse`。放它们
424
+ * 过去,`getLatest()` 会按数组用它 —— `null` 在启动路径(`index.tsx` 建系统提示时)
425
+ * 抛 `TypeError: index.length`;`[1,2]` 更安静:`getLatest()` 返回数字 `1`,调用方接着
426
+ * 读 `latest.name` 拿到 `undefined`,再喂给 `load()` ⇒ `name.replace` 抛。条目还必须
427
+ * 有**字符串 name**:索引条目的第一用途就是当文件名用。
417
428
  */
418
429
  private static loadIndexRaw(): SessionIndexEntry[] {
419
- if (!existsSync(INDEX_FILE)) return []
430
+ const raw = readRegularFileSync(INDEX_FILE)
431
+ if (raw === null) return []
420
432
  try {
421
- return JSON.parse(readFileSync(INDEX_FILE, 'utf-8'))
433
+ const parsed: unknown = JSON.parse(raw)
434
+ if (!Array.isArray(parsed)) return []
435
+ return parsed.filter(
436
+ (e): e is SessionIndexEntry =>
437
+ !!e &&
438
+ typeof e === 'object' &&
439
+ typeof (e as { name?: unknown }).name === 'string' &&
440
+ (e as { name: string }).name.length > 0,
441
+ )
422
442
  } catch {
423
443
  return []
424
444
  }
@@ -1,6 +1,9 @@
1
1
  // apps/cli/src/daemon/attach-protocol.ts
2
- // Client → Daemon: prompt, interrupt
3
- // Daemon → Client: text, tool_use, tool_result, usage, task_notification, done, error, session_state
2
+ // Client → Daemon: prompt, interrupt, set_mode
3
+ // Daemon → Client: text, tool_use, tool_result, usage, task_notification, done, error,
4
+ // session_state, mode
5
+
6
+ import type { PermissionMode } from '../shared/types'
4
7
 
5
8
  export interface ClientPromptMessage {
6
9
  type: 'prompt'
@@ -11,7 +14,18 @@ export interface ClientInterruptMessage {
11
14
  type: 'interrupt'
12
15
  sessionId: string
13
16
  }
14
- export type ClientMessage = ClientPromptMessage | ClientInterruptMessage
17
+ export interface ClientSetModeMessage {
18
+ type: 'set_mode'
19
+ sessionId: string
20
+ /**
21
+ * 请求的档位。daemon 会先过白名单、再走组织级限制的钳制,然后回播**生效**的那一档。
22
+ *
23
+ * `sessionId` 与 `prompt` / `interrupt` 一样是协议对称用的:daemon 一概以
24
+ * `ws.data.sessionId` 为准(否则一个 attach 就能改**别的**会话的闸门)。
25
+ */
26
+ mode: PermissionMode
27
+ }
28
+ export type ClientMessage = ClientPromptMessage | ClientInterruptMessage | ClientSetModeMessage
15
29
 
16
30
  export interface ServerTextMessage {
17
31
  type: 'text'
@@ -61,6 +75,18 @@ export interface ServerSessionStateMessage {
61
75
  provider: string
62
76
  model: string
63
77
  turnCount: number
78
+ /** 本会话当前**生效**的档位 —— 新 attach 的客户端据此初始化页脚,而不是猜 `default`。 */
79
+ mode: PermissionMode
80
+ }
81
+ /**
82
+ * 回播生效档位。两个触发点:客户端发来 `set_mode`(含被拒的请求 —— 那时回播的是
83
+ * **当前**档),以及 `set_mode` 施加后。带的是 `PermissionSystem.getMode()` 的读数,
84
+ * 即**钳制之后**的值:报请求值就是那条老缺陷的形状 —— 说放行、实际审批。
85
+ */
86
+ export interface ServerModeMessage {
87
+ type: 'mode'
88
+ sessionId: string
89
+ mode: PermissionMode
64
90
  }
65
91
 
66
92
  export type ServerMessage =
@@ -72,3 +98,4 @@ export type ServerMessage =
72
98
  | ServerDoneMessage
73
99
  | ServerErrorMessage
74
100
  | ServerSessionStateMessage
101
+ | ServerModeMessage
@@ -1,6 +1,7 @@
1
1
  // apps/cli/src/daemon/auth.ts
2
2
  import { randomBytes, timingSafeEqual } from 'node:crypto'
3
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
3
+ import { readFileSync, existsSync, mkdirSync } from 'node:fs'
4
+ import { atomicWriteFileSync } from '../shared/atomic-write'
4
5
  import { dirname } from 'node:path'
5
6
 
6
7
  /**
@@ -21,7 +22,7 @@ export function loadOrCreateToken(tokenPath: string): string {
21
22
 
22
23
  const token = generateToken()
23
24
  mkdirSync(dirname(tokenPath), { recursive: true, mode: 0o700 })
24
- writeFileSync(tokenPath, token, { mode: 0o600 })
25
+ atomicWriteFileSync(tokenPath, token, { mode: 0o600 })
25
26
  return token
26
27
  }
27
28
 
@@ -51,7 +52,7 @@ export function verifyToken(expected: string, provided: string): boolean {
51
52
  */
52
53
  export function rotateToken(tokenPath: string): string {
53
54
  const token = generateToken()
54
- writeFileSync(tokenPath, token, { mode: 0o600 })
55
+ atomicWriteFileSync(tokenPath, token, { mode: 0o600 })
55
56
  return token
56
57
  }
57
58
 
@@ -6,7 +6,8 @@
6
6
  // getPort() reads the last known port from disk
7
7
 
8
8
  import { join } from 'node:path'
9
- import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs'
9
+ import { existsSync, readFileSync, unlinkSync, mkdirSync } from 'node:fs'
10
+ import { atomicWriteFileSync } from '../shared/atomic-write'
10
11
  import { createServer as createNetServer } from 'node:net'
11
12
  import type { Server } from 'bun'
12
13
  import { DaemonDatabase } from './database'
@@ -247,8 +248,8 @@ export async function startDaemon(): Promise<{ port: number; token: string }> {
247
248
  activeServer = server
248
249
 
249
250
  // Write PID and port files
250
- writeFileSync(PID_FILE, String(process.pid))
251
- writeFileSync(PORT_FILE, String(port))
251
+ atomicWriteFileSync(PID_FILE, String(process.pid), { mode: 0o644 })
252
+ atomicWriteFileSync(PORT_FILE, String(port), { mode: 0o644 })
252
253
 
253
254
  return { port, token }
254
255
  }