@tyhld/conductor 0.12.82 → 0.12.84

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -488,12 +488,15 @@ function printUsage() {
488
488
  ' conductor ear この機に1本の「耳」(合図の受け取り役)',
489
489
  ' conductor build-runner この機に1本の「司令塔」(試作の段取り役)',
490
490
  ' conductor setup <現場名> [フォルダ] [--profile <default|proto>] 采配くんをこのPCに設置する(初回の1回だけ)',
491
- ' conductor view <現場名> [--detail] その現場の今を見るだけの画面(kc の実体)',
491
+ ' conductor view <現場名> [--once] [--detail] その現場の今を見るだけの画面(kc の実体)',
492
492
  '',
493
493
  '見るだけの画面(conductor view = kc 現場名):',
494
494
  ' いま職人が何をしているかを、やさしい日本語で見るだけの画面です。',
495
+ ' ★既定は「開いたまま流れ続ける」形です。職人の動きが出るたびに下へ流れます。',
496
+ ' 閉じるときは Ctrl+C(★抜けても職人・見守り役には何も起きません)。',
495
497
  ' ★職人(Claude Code)・tmux・relay を起動しません。入力も受け付けません(抜けるだけ)。',
496
- ' --detail を付けると、職人の生の記録(秘密は隠したもの)も続けて出します。',
498
+ ' --once これまでどおり「いまの様子を1回だけ」出して終わります。',
499
+ ' --detail 職人の生の記録(秘密は隠したもの)も続けて出します。',
497
500
  '',
498
501
  '設置の型(--profile。既定は default=今までどおり):',
499
502
  ' default ふつうの現場。従来と同じ設定を置きます。',
@@ -573,7 +576,8 @@ async function main() {
573
576
  break;
574
577
  case 'view':
575
578
  // ★見るだけの画面。職人も tmux も relay も起動せず、入力も受け付けない。
576
- process.exit(runViewCommand(process.argv.slice(3)));
579
+ // 既定は「開いたまま流れ続ける」ので、Ctrl+C まで戻らない(--once は1回で戻る)。
580
+ process.exit(await runViewCommand(process.argv.slice(3)));
577
581
  break;
578
582
  case 'version':
579
583
  case '-v':
@@ -0,0 +1,782 @@
1
+ /**
2
+ * @tyhld/conductor — `kc 現場名` を「開いたまま流れ続ける画面」にする(便 a56e49bf)。
3
+ *
4
+ * 【なぜ要るか(えふさんの言葉 2026-10-02)】
5
+ * 「いつ前みたいに見れるようになる?」
6
+ * 画面あり方式(tmux)の頃は、職人の画面を開いておけば、考えていること・
7
+ * 打っているコマンド・その結果がそのまま流れて見えた。画面なし方式にしてから、
8
+ * `kc 現場名` は【開いた瞬間の様子を1回出して終わる】ので、開いたまま眺めることが
9
+ * できなくなっていた。=この画面を「開いたまま流れ続ける」形に戻す。
10
+ *
11
+ * 【えふさん確定(2026-10-02・判断7)】
12
+ * 開いたまま自動で流れ続け、職人の言葉をもっと細かく出すようにする。
13
+ * 見るだけの画面という決まりは変えません。
14
+ *
15
+ * 【どこから読むか(★ここが設計の肝)】
16
+ * 流れの出どころは【この PC の中にある生の記録】(runs/便ID/attempt-N.jsonl)だけ。
17
+ * ・管制へ送る進捗(attempt-N.events.jsonl)は、外へ出す分を絞った控えなので粗い。
18
+ * ★そちらを細かくすると「PC の外へ出す量」が増える。だから外へ送る物は1バイトも
19
+ * 増やさず、手元で読める生の記録を人の言葉に直して画面にだけ出す。
20
+ * ・生の記録の1行には時刻(timestamp)が入っている(実測 2026-10-02・この現場の便)。
21
+ * だから手元だけで時刻つきの流れが作れる。
22
+ *
23
+ * 【この画面の約束(1つも緩めない)】
24
+ * ① 読むだけ。職人・作業部屋(tmux)・見守り役を起動しない。管制へ通信しない。
25
+ * ② 職人へ何も送らない(入力を渡さない・止めない・再開しない)。
26
+ * ③ 秘密は出す前に必ず隠す(maskSecrets)。1行の長さに上限を持つ。
27
+ * ④ 生の記録はこの PC の外へ出さない(画面に出すだけ)。
28
+ * ⑤ 分からないことを「ありません」と断定しない。理由と次の1手を日本語で出す。
29
+ */
30
+ import { closeSync, existsSync, openSync, readSync, statSync } from 'node:fs';
31
+ import os from 'node:os';
32
+ import { extractProgressLine, loadActiveJob, loadLastJob, maskSecrets, } from './headless.js';
33
+ import { DETAIL_LINE_CHARS_MAX, LAST_OUTCOME_LABEL, STATUS_LABEL, STATUS_NOTE, collectSiteView, friendlyDuration, friendlyTime, hhmm, jobTitleFromBody, quietActivityNote, rawLogPath, } from './view.js';
34
+ // ─────────────────────────────────────────────────────────────────────────────
35
+ // 定数(★線引きはここ1か所)
36
+ // ─────────────────────────────────────────────────────────────────────────────
37
+ /**
38
+ * 新しい記録を見に行く間隔(ms)。
39
+ * 【なぜ1秒か】人が「流れている」と感じるのは1秒くらいまで。これより短くすると
40
+ * ファイルを覗く回数だけが増えて画面は変わらない。これより長いと、打ったコマンドの
41
+ * 反応が遅れて「止まったのでは」と見える。
42
+ */
43
+ export const FOLLOW_POLL_MS = 1_000;
44
+ /**
45
+ * 開いたときに「これまでの分」を何行まで振り返って出すか。
46
+ * 【なぜ15行か】画面を開いた人が「いま何の途中か」を読み取れる最小限。
47
+ * 多くすると、開いた瞬間に昔の行で画面が埋まって、いまの動きが見えなくなる。
48
+ */
49
+ export const FOLLOW_BACKLOG_LINES = 15;
50
+ /** 振り返りのために読む生の記録の末尾の大きさ(バイト)。★全部は読まない。 */
51
+ export const FOLLOW_BACKLOG_BYTES = 512 * 1024;
52
+ /**
53
+ * 画面に出す1行の長さの上限(文字)。
54
+ * 【なぜ160字か】生の記録の1行は数千文字ある(試験の出力・ファイルの中身)。
55
+ * そのまま出すと1件で画面が埋まって流れが読めない。端末の2行ぶんで切る。
56
+ */
57
+ export const FOLLOW_LINE_CHARS_MAX = 160;
58
+ /** 記録1行から画面へ出す行数の上限(件)。1つの行に大量に詰まっていても溢れさせない。 */
59
+ export const FOLLOW_LINES_PER_RECORD_MAX = 4;
60
+ /** 画面の区切り線(通常表示と同じ形)。 */
61
+ const RULE = '────────────────────';
62
+ // ─────────────────────────────────────────────────────────────────────────────
63
+ // 文字の整え
64
+ // ─────────────────────────────────────────────────────────────────────────────
65
+ /** 上限で切る(切ったら「…」を付ける)。 */
66
+ export function clip(text, max = FOLLOW_LINE_CHARS_MAX) {
67
+ const chars = [...text];
68
+ return chars.length > max ? `${chars.slice(0, max).join('')}…` : text;
69
+ }
70
+ /** 長い文の「要点」=最初の意味のある1行。残りの行数も返す。 */
71
+ export function gist(text) {
72
+ const lines = (text ?? '').split('\n').map((l) => l.trim()).filter((l) => l.length > 0);
73
+ if (lines.length === 0)
74
+ return { head: '', moreLines: 0 };
75
+ return { head: lines[0], moreLines: lines.length - 1 };
76
+ }
77
+ /** ホームの下は `~/` で出す(画面が横に長くならないように)。 */
78
+ export function shortPath(p) {
79
+ const home = os.homedir();
80
+ return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
81
+ }
82
+ /** 記録の中の「文字か、文字の配列か、text ブロックの配列」を1本の文字列にする。 */
83
+ function flatten(content) {
84
+ if (typeof content === 'string')
85
+ return content;
86
+ if (Array.isArray(content)) {
87
+ return content
88
+ .map((b) => {
89
+ if (typeof b === 'string')
90
+ return b;
91
+ if (typeof b === 'object' && b !== null)
92
+ return String(b.text ?? '');
93
+ return '';
94
+ })
95
+ .filter((s) => s.length > 0)
96
+ .join('\n');
97
+ }
98
+ return '';
99
+ }
100
+ // ─────────────────────────────────────────────────────────────────────────────
101
+ // 道具1件を「これからやること」の日本語にする
102
+ // ─────────────────────────────────────────────────────────────────────────────
103
+ /** 外の道具(MCP)の名前 `mcp__どこ__なに` の「なに」だけを取る。 */
104
+ function mcpToolName(name) {
105
+ const m = /^mcp__[^_]+(?:_[^_]+)*__(.+)$/.exec(name);
106
+ return m ? m[1] : null;
107
+ }
108
+ /**
109
+ * 道具1件を人が読める日本語にする(1件で1〜2行)。
110
+ *
111
+ * ★英語の内部用語をそのまま出さない(えふさん確定:非エンジニア・60代でも読める日本語)。
112
+ * ★「実行したコマンド」は、えふさんが見たいと言った物なので出す(★手元の画面だけ)。
113
+ */
114
+ export function toolLinesJa(name, input) {
115
+ const str = (key) => {
116
+ const v = input?.[key];
117
+ return typeof v === 'string' ? v.trim() : '';
118
+ };
119
+ const desc = str('description');
120
+ const file = str('file_path') || str('notebook_path') || str('path');
121
+ const out = [];
122
+ switch (name) {
123
+ case 'Bash':
124
+ case 'BashOutput': {
125
+ out.push(`・${desc.length > 0 ? desc : 'コマンドを実行します'}`);
126
+ const cmd = str('command');
127
+ if (cmd.length > 0)
128
+ out.push(` $ ${gist(cmd).head}`);
129
+ return out;
130
+ }
131
+ case 'Read':
132
+ return [`・ファイルを読みます:${shortPath(file)}`];
133
+ case 'Write':
134
+ return [`・ファイルを書きます:${shortPath(file)}`];
135
+ case 'Edit':
136
+ case 'NotebookEdit':
137
+ return [`・ファイルを直します:${shortPath(file)}`];
138
+ case 'Glob':
139
+ case 'Grep': {
140
+ const pat = str('pattern');
141
+ const where = str('path');
142
+ return [`・探します:${pat}${where.length > 0 ? `(${shortPath(where)})` : ''}`];
143
+ }
144
+ case 'Agent':
145
+ case 'Task':
146
+ return [`・下請けの職人に頼みます:${desc.length > 0 ? desc : str('subagent_type')}`];
147
+ case 'Skill':
148
+ return [`・手順書を開きます:${str('skill')}`];
149
+ case 'TodoWrite':
150
+ return ['・やることの一覧を整え直しています'];
151
+ case 'WebFetch':
152
+ return [`・外のページを読みます:${str('url')}`];
153
+ case 'WebSearch':
154
+ return [`・調べます:${str('query')}`];
155
+ case 'ToolSearch':
156
+ return ['・使える道具を探しています'];
157
+ default:
158
+ break;
159
+ }
160
+ const mcp = mcpToolName(name);
161
+ if (mcp !== null) {
162
+ return [`・外の仕組みを使います:${mcp}${desc.length > 0 ? `(${desc})` : ''}`];
163
+ }
164
+ if (desc.length > 0)
165
+ return [`・${desc}`];
166
+ if (file.length > 0)
167
+ return [`・${name}:${shortPath(file)}`];
168
+ return [`・${name} を使います`];
169
+ }
170
+ /**
171
+ * 道具の結果1件を「結果の要点」の日本語にする。
172
+ *
173
+ * ★どの道具の結果かが分かるときは、英語の出力をそのまま出さずに日本語でまとめる
174
+ * (実測:ファイルを直した結果は "The file ... has been updated successfully" と
175
+ * 英語で返るので、そのまま出すとお客さまには読めない)。
176
+ * ★コマンドの出力(Bash)は、そのプログラム自身の答えなので要点をそのまま出す
177
+ * (画面あり方式のときに見えていた物=えふさんが見たいと言った物)。
178
+ */
179
+ export function resultLineJa(toolName, content) {
180
+ const { head, moreLines } = gist(content);
181
+ const lineCount = head.length === 0 ? 0 : moreLines + 1;
182
+ const asIs = () => ` → ${head}${moreLines > 0 ? `(ほか${moreLines}行)` : ''}`;
183
+ switch (toolName) {
184
+ case 'Read':
185
+ return ` → 読みました(${lineCount}行)`;
186
+ case 'Write':
187
+ case 'Edit':
188
+ case 'NotebookEdit':
189
+ return ' → 書き込みました';
190
+ case 'Glob':
191
+ case 'Grep':
192
+ return ` → ${lineCount}件見つかりました`;
193
+ case 'Agent':
194
+ case 'Task':
195
+ return ' → 下請けの職人から返事が来ました';
196
+ case 'Skill':
197
+ return ' → 手順書を読み込みました';
198
+ case 'TodoWrite':
199
+ return ' → やることの一覧を整えました';
200
+ case 'WebFetch':
201
+ case 'WebSearch':
202
+ return ` → 受け取りました(${lineCount}行)`;
203
+ default:
204
+ return asIs();
205
+ }
206
+ }
207
+ /** 道具の呼び出しと結果を結びつける覚え(★古い分は捨てる)。 */
208
+ export const TOOL_NAME_MEMO_MAX = 500;
209
+ function rememberToolName(memo, id, name) {
210
+ if (memo === undefined || id.length === 0)
211
+ return;
212
+ memo.set(id, name);
213
+ if (memo.size > TOOL_NAME_MEMO_MAX) {
214
+ const oldest = memo.keys().next();
215
+ if (!oldest.done)
216
+ memo.delete(oldest.value);
217
+ }
218
+ }
219
+ /**
220
+ * 「止まった理由」として出す値打ちのある差し込み文の形。
221
+ * ★手順書の読み込みなどの差し込みは出さない(量が多く、読む人の役に立たない)。
222
+ */
223
+ export const INTERRUPT_RE = /interrupt|cancel|API Error|rate limit|timed out|割り込み|中断|止めました|やめました/i;
224
+ /** 人の確認の種類を日本語にする。 */
225
+ function askLabel(hookEvent) {
226
+ if (hookEvent === 'PermissionRequest')
227
+ return '許可の確認';
228
+ if (hookEvent === 'Notification')
229
+ return '知らせ';
230
+ return hookEvent;
231
+ }
232
+ // ─────────────────────────────────────────────────────────────────────────────
233
+ // 生の記録1行 → 画面の行(★ここが「細かく出す」本体)
234
+ // ─────────────────────────────────────────────────────────────────────────────
235
+ /**
236
+ * 生の記録(stream-json)の1行を、人が読める日本語の行にする。
237
+ *
238
+ * 【出す物(えふさん確定の「もっと細かく」)】
239
+ * ・職人の言葉(考え・これからやること)
240
+ * ・実行したコマンド
241
+ * ・読んだ/書いたファイル
242
+ * ・結果の要点
243
+ * ・質問や止まった理由
244
+ * 【出さない物】量が多いだけで読めない物(考えた字数の報告・道具一覧の更新など)。
245
+ *
246
+ * ★秘密は隠してから切る(切ってから隠すと、隠す形が壊れて漏れうる)。
247
+ */
248
+ export function humanLinesFromRecord(parsed, toolNames) {
249
+ const at = (() => {
250
+ const t = parsed.timestamp;
251
+ if (typeof t !== 'string')
252
+ return null;
253
+ const ms = Date.parse(t);
254
+ return Number.isFinite(ms) ? ms : null;
255
+ })();
256
+ const type = String(parsed.type ?? '');
257
+ const subtype = typeof parsed.subtype === 'string' ? parsed.subtype : '';
258
+ const out = [];
259
+ const push = (kind, raw) => {
260
+ if (out.length >= FOLLOW_LINES_PER_RECORD_MAX)
261
+ return;
262
+ // ★行の頭の空白は残す(コマンドと結果を1段下げて出すため)。後ろだけ落とす。
263
+ const text = clip(maskSecrets(raw).trimEnd());
264
+ if (text.length === 0)
265
+ return;
266
+ out.push({ at, kind, text });
267
+ };
268
+ const content = (() => {
269
+ const msg = parsed.message;
270
+ if (typeof msg !== 'object' || msg === null)
271
+ return null;
272
+ const c = msg.content;
273
+ return Array.isArray(c) ? c : null;
274
+ })();
275
+ if (type === 'assistant' && content !== null) {
276
+ for (const block of content) {
277
+ if (block?.type === 'thinking') {
278
+ const { head } = gist(String(block.thinking ?? block.text ?? ''));
279
+ if (head.length > 0)
280
+ push('think', `考えています:${head}`);
281
+ }
282
+ else if (block?.type === 'text') {
283
+ const text = String(block.text ?? '');
284
+ const marked = extractProgressLine(text);
285
+ if (marked !== null) {
286
+ push('progress', `▶ ${marked}`);
287
+ }
288
+ else {
289
+ const { head, moreLines } = gist(text);
290
+ if (head.length > 0) {
291
+ push('say', `職人:${head}${moreLines > 0 ? `(ほか${moreLines}行)` : ''}`);
292
+ }
293
+ }
294
+ }
295
+ else if (block?.type === 'tool_use') {
296
+ const input = typeof block.input === 'object' && block.input !== null
297
+ ? block.input
298
+ : undefined;
299
+ const name = String(block.name ?? '不明な道具');
300
+ // 結果の行を日本語でまとめるために、どの道具の呼び出しだったかを覚えておく。
301
+ rememberToolName(toolNames, String(block.id ?? ''), name);
302
+ for (const line of toolLinesJa(name, input)) {
303
+ push(line.startsWith(' $ ') ? 'cmd' : 'tool', line);
304
+ }
305
+ }
306
+ // ほかの種類(画像・使い途の無い印)は出さない。
307
+ }
308
+ return out;
309
+ }
310
+ if (type === 'user' && content !== null) {
311
+ for (const block of content) {
312
+ if (block?.type === 'tool_result') {
313
+ const body = flatten(block.content);
314
+ if (block.is_error === true) {
315
+ const { head } = gist(body);
316
+ push('error', `⚠ うまくいきませんでした:${head.length > 0 ? head : '理由は記録にありません'}`);
317
+ }
318
+ else if (gist(body).head.length > 0) {
319
+ const name = toolNames?.get(String(block.tool_use_id ?? ''));
320
+ push('result', resultLineJa(name, body));
321
+ }
322
+ }
323
+ else if (block?.type === 'text' && parsed.isSynthetic === true) {
324
+ // 仕組みが差し込む文。★ふつうは手順書の読み込みなので出さない(量が多く、読めない)。
325
+ // 止まった理由(割り込み・通信の失敗)だけを拾う。
326
+ const { head } = gist(String(block.text ?? ''));
327
+ if (head.length > 0 && INTERRUPT_RE.test(head))
328
+ push('info', `※ ${head}`);
329
+ }
330
+ }
331
+ return out;
332
+ }
333
+ if (type === 'result') {
334
+ if (parsed.is_error === true || subtype === 'error_during_execution') {
335
+ const text = String(parsed.result ?? '') || flatten(parsed.errors) || `失敗しました(${subtype || 'result'})`;
336
+ push('end', `⚠ 失敗で終わりました:${gist(text).head}`);
337
+ }
338
+ else {
339
+ push('end', '作業が終わりました');
340
+ }
341
+ return out;
342
+ }
343
+ if (type === 'system') {
344
+ if (subtype === 'init') {
345
+ push('start', '職人が動き始めました');
346
+ }
347
+ else if (subtype === 'hook_started') {
348
+ const ev = String(parsed.hook_event ?? '');
349
+ if (ev === 'PermissionRequest' || ev === 'Notification') {
350
+ push('ask', `🔵 人の確認を待っています(${askLabel(ev)})`);
351
+ }
352
+ }
353
+ else if (subtype === 'hook_response') {
354
+ const ev = String(parsed.hook_event ?? '');
355
+ if (ev === 'PermissionRequest' || ev === 'Notification') {
356
+ push('info', '※ 確認の返事が返りました(作業を続けます)');
357
+ }
358
+ }
359
+ return out;
360
+ }
361
+ if (type === 'rate_limit_event') {
362
+ // ★ふつうの知らせ(status='allowed')は出さない。出すと「混んでいる」という嘘になる。
363
+ const info = parsed.rate_limit_info;
364
+ const status = typeof info === 'object' && info !== null ? String(info.status ?? '') : '';
365
+ if (status === 'rejected') {
366
+ push('info', '※ 使える量の上限に達しました(時間をおくと再開できます)');
367
+ }
368
+ else if (status.length > 0 && status !== 'allowed') {
369
+ push('info', '※ 使える量の上限に近づいています');
370
+ }
371
+ return out;
372
+ }
373
+ return out;
374
+ }
375
+ // ─────────────────────────────────────────────────────────────────────────────
376
+ // 便の題名(「何を良くする仕事か」の冒頭)
377
+ // ─────────────────────────────────────────────────────────────────────────────
378
+ /**
379
+ * 便の本文から「何を良くする仕事か」の冒頭を取る。
380
+ *
381
+ * ★管制は短い題名を返していない(実測 2026-10-02)。本文の先頭は配達の前置き
382
+ * (「devlog の MCP はつながっています…」)なので、そのまま出すとお客さまには読めない。
383
+ * 本文の中に【何を良くする仕事か】の節が在れば、そこの1行目を使う。
384
+ */
385
+ export function jobHeadline(body, max = 70) {
386
+ const text = (body ?? '').replace(/\r/g, '');
387
+ const m = /【\s*何を良くする仕事か\s*】([\s\S]*)/.exec(text);
388
+ if (m) {
389
+ const line = m[1]
390
+ .split('\n')
391
+ .map((l) => l.trim())
392
+ .find((l) => l.length > 0 && !l.startsWith('【'));
393
+ if (line !== undefined)
394
+ return clip(line, max);
395
+ }
396
+ return jobTitleFromBody(text, max);
397
+ }
398
+ /**
399
+ * 「開いたまま流れ続ける画面」。
400
+ *
401
+ * ★1周(poll)ごとに、便の切れ目と新しい記録だけを出す。
402
+ * 同じ記録を2回出さないために、読んだ場所(バイト)を覚えておく。
403
+ */
404
+ export class FollowSession {
405
+ deps;
406
+ cur = null;
407
+ /** 直前に出した行(同じ文が続いたときに1行にまとめる)。 */
408
+ prevText = null;
409
+ /** 直前に出した行の時刻(時刻の無い記録に使う)。 */
410
+ lastAt = null;
411
+ waitingNoted = false;
412
+ oldRelayNoted = false;
413
+ unreadableNoted = false;
414
+ troubleNoted = false;
415
+ /** 道具の呼び出し(id)→ 道具の名前。結果の行を日本語でまとめるために覚えておく。 */
416
+ toolNames = new Map();
417
+ constructor(deps) {
418
+ this.deps = deps;
419
+ }
420
+ now() {
421
+ return this.deps.now?.() ?? Date.now();
422
+ }
423
+ out(line) {
424
+ this.deps.out(line);
425
+ }
426
+ active() {
427
+ return (this.deps.loadActive ?? loadActiveJob)(this.deps.site);
428
+ }
429
+ last() {
430
+ return (this.deps.loadLast ?? loadLastJob)(this.deps.site);
431
+ }
432
+ rawPath(commandId, attempt) {
433
+ return (this.deps.rawPath ?? rawLogPath)(commandId, attempt);
434
+ }
435
+ /**
436
+ * 何かでつまずいても、英語の難しい文のまま落ちないようにする。
437
+ * ★黙って固まらない・理由と次の1手を日本語で出す(えふさん確定の決まり)。
438
+ */
439
+ safely(what, fn) {
440
+ try {
441
+ fn();
442
+ }
443
+ catch (err) {
444
+ if (this.troubleNoted)
445
+ return;
446
+ this.troubleNoted = true;
447
+ const why = err instanceof Error ? err.message : String(err);
448
+ this.out(`※${what}でつまずきました(${clip(maskSecrets(why), 80)})。`);
449
+ this.out(' 次の1手:Ctrl+C で閉じて、もう一度開いてください。何度も出るならサポートへこの画面をお送りください。');
450
+ }
451
+ }
452
+ /** 開いたときの1回:いまの状態 → これまでの流れ → 以後は poll で続く。 */
453
+ open() {
454
+ this.safely('画面の用意', () => {
455
+ this.out(this.header());
456
+ const active = this.active();
457
+ if (active !== null) {
458
+ this.beginFollow(active, { announce: false, backlog: true });
459
+ }
460
+ else {
461
+ this.noteWaiting();
462
+ }
463
+ });
464
+ }
465
+ /** 1周ぶん。便の切れ目と、増えた記録だけを出す。 */
466
+ poll() {
467
+ this.safely('様子の読み取り', () => {
468
+ this.pollOnce();
469
+ });
470
+ }
471
+ pollOnce() {
472
+ const active = this.active();
473
+ if (active === null) {
474
+ if (this.cur !== null) {
475
+ this.readNew(); // 残っている記録を出し切る
476
+ this.printJobEnd(this.cur);
477
+ this.cur = null;
478
+ }
479
+ this.noteWaiting();
480
+ return;
481
+ }
482
+ if (this.cur === null) {
483
+ this.beginFollow(active, { announce: true, backlog: false });
484
+ }
485
+ else if (this.cur.commandId !== active.commandId || this.cur.attempt !== active.attempt) {
486
+ this.readNew(); // 前の便の残りを出し切る
487
+ this.printJobEnd(this.cur);
488
+ this.cur = null;
489
+ this.beginFollow(active, { announce: true, backlog: false });
490
+ }
491
+ this.readNew();
492
+ }
493
+ /** いまの状態の見出し(通常表示の上半分と同じ言い方)。 */
494
+ header() {
495
+ const nowMs = this.now();
496
+ if (this.deps.header !== undefined)
497
+ return this.deps.header(this.deps.site, nowMs);
498
+ const v = collectSiteView(this.deps.site, nowMs);
499
+ const unknown = '—(確かめられません)';
500
+ const body = v.active?.body ?? v.last?.body ?? '';
501
+ const headline = jobHeadline(body);
502
+ const lines = [];
503
+ lines.push(`${v.site} ${STATUS_LABEL[v.status]} ${STATUS_NOTE[v.status]}`);
504
+ lines.push(`仕事:${headline.length > 0 ? headline : unknown}`);
505
+ if (v.postedAt !== null)
506
+ lines.push(`投函:${friendlyTime(v.postedAt, nowMs)}`);
507
+ lines.push(`開始:${v.startedAt !== null ? friendlyTime(v.startedAt, nowMs) : '—(まだ起動していません)'}`);
508
+ if (v.startedAt !== null) {
509
+ const until = v.status === 'working' ? nowMs : (v.lastActivityAt ?? v.last?.finishedAt ?? nowMs);
510
+ lines.push(`実作業:${friendlyDuration(until - v.startedAt)}`);
511
+ }
512
+ const sinceActivityMs = v.lastActivityAt === null ? null : nowMs - v.lastActivityAt;
513
+ if (v.lastActivityAt !== null) {
514
+ lines.push(`最後に動いた:${friendlyTime(v.lastActivityAt, nowMs)}(${friendlyDuration(sinceActivityMs ?? 0)}前)`);
515
+ }
516
+ if (v.status === 'working') {
517
+ const note = quietActivityNote(sinceActivityMs);
518
+ if (note !== null)
519
+ lines.push(`※${note}`);
520
+ }
521
+ lines.push('');
522
+ lines.push('※見るだけの画面です(職人へは何も送りません/職人も作業部屋も見守り役も起動しません)');
523
+ lines.push('※開いたままにしておくと、下へ流れ続けます。閉じるときは Ctrl+C を押してください');
524
+ lines.push(RULE);
525
+ return lines.join('\n');
526
+ }
527
+ /** この便を追い始める。 */
528
+ beginFollow(active, opts) {
529
+ if (opts.announce) {
530
+ this.out(RULE);
531
+ this.out(`── 新しい便が始まりました(${friendlyTime(active.startedAt, this.now())})`);
532
+ const headline = jobHeadline(active.body);
533
+ this.out(`仕事:${headline.length > 0 ? headline : '—(確かめられません)'}`);
534
+ this.out(RULE);
535
+ }
536
+ this.waitingNoted = false;
537
+ this.prevText = null;
538
+ // ★最初の行に時刻が無いとき(職人の起動の知らせ)は、便の開始時刻を使う。
539
+ this.lastAt = Number.isFinite(active.startedAt) ? active.startedAt : null;
540
+ this.toolNames = new Map();
541
+ this.cur = {
542
+ commandId: active.commandId,
543
+ attempt: active.attempt,
544
+ offset: 0,
545
+ partial: Buffer.alloc(0),
546
+ missingNoted: false,
547
+ };
548
+ // ★見守り役が古い版だと、職人の番号も細かい記録も残らない。黙って固まらずに理由を出す。
549
+ if (active.pid === undefined && !this.oldRelayNoted) {
550
+ this.oldRelayNoted = true;
551
+ this.out('※このPCの見守り役は古い版のようです(職人の番号が記録に入っていません)。');
552
+ this.out(' 次の1手:新しい版を配ってから、現場ごとに見守り役を入れ直してください(人の窓の作業です)。');
553
+ }
554
+ if (opts.backlog)
555
+ this.readBacklog();
556
+ }
557
+ /** 開いたときの「これまでの分」を、末尾だけ振り返って出す。 */
558
+ readBacklog() {
559
+ const cur = this.cur;
560
+ if (cur === null)
561
+ return;
562
+ const p = this.rawPath(cur.commandId, cur.attempt);
563
+ const chunk = readTail(p, FOLLOW_BACKLOG_BYTES);
564
+ if (chunk === null)
565
+ return;
566
+ // 途中から読んだときは、先頭の切れた行を捨てる。
567
+ const text = chunk.startedMidFile
568
+ ? chunk.text.slice(chunk.text.indexOf('\n') + 1)
569
+ : chunk.text;
570
+ const lines = [];
571
+ for (const raw of text.split('\n')) {
572
+ for (const l of this.linesFromRawText(raw))
573
+ lines.push(l);
574
+ }
575
+ const max = this.deps.backlogLines ?? FOLLOW_BACKLOG_LINES;
576
+ const shown = lines.slice(-max);
577
+ if (lines.length > shown.length) {
578
+ this.out(`(これより前の ${lines.length - shown.length} 行は省きました)`);
579
+ }
580
+ for (const l of shown)
581
+ this.print(l);
582
+ if (shown.length === 0)
583
+ this.out('(まだ職人の動きが記録されていません。出たらここに流れます)');
584
+ cur.offset = chunk.size;
585
+ cur.partial = Buffer.alloc(0);
586
+ }
587
+ /** 増えた分だけを読んで出す(★同じ記録を2回読まないために場所を覚えている)。 */
588
+ readNew() {
589
+ const cur = this.cur;
590
+ if (cur === null)
591
+ return;
592
+ const p = this.rawPath(cur.commandId, cur.attempt);
593
+ let fd = null;
594
+ try {
595
+ if (!existsSync(p)) {
596
+ if (!cur.missingNoted) {
597
+ cur.missingNoted = true;
598
+ this.out('※職人の記録のファイルがまだ見つかりません。');
599
+ this.out(' 次の1手:見守り役が新しい版かを確かめてください(古い版では細かい記録が残りません)。');
600
+ }
601
+ return;
602
+ }
603
+ const size = statSync(p).size;
604
+ if (size < cur.offset) {
605
+ // 記録が作り直された(attempt のやり直しなど)。先頭から読み直す。
606
+ this.out('※記録が作り直されました。ここから続けて出します。');
607
+ cur.offset = 0;
608
+ cur.partial = Buffer.alloc(0);
609
+ }
610
+ if (size === cur.offset)
611
+ return;
612
+ fd = openSync(p, 'r');
613
+ const len = size - cur.offset;
614
+ const buf = Buffer.alloc(len);
615
+ const got = readSync(fd, buf, 0, len, cur.offset);
616
+ cur.offset += got;
617
+ const all = Buffer.concat([cur.partial, buf.subarray(0, got)]);
618
+ const lastBreak = all.lastIndexOf(0x0a); // '\n'
619
+ if (lastBreak < 0) {
620
+ cur.partial = all;
621
+ return;
622
+ }
623
+ cur.partial = all.subarray(lastBreak + 1);
624
+ const text = all.subarray(0, lastBreak).toString('utf8');
625
+ for (const raw of text.split('\n')) {
626
+ for (const l of this.linesFromRawText(raw))
627
+ this.print(l);
628
+ }
629
+ }
630
+ catch (err) {
631
+ if (!this.unreadableNoted) {
632
+ this.unreadableNoted = true;
633
+ const why = err instanceof Error ? err.message : String(err);
634
+ this.out(`※職人の記録を読めませんでした(${clip(maskSecrets(why), 80)})。`);
635
+ this.out(' 次の1手:少し待ってからもう一度開いてください。何度も出るならサポートへこの画面をお送りください。');
636
+ }
637
+ }
638
+ finally {
639
+ if (fd !== null) {
640
+ try {
641
+ closeSync(fd);
642
+ }
643
+ catch {
644
+ // 閉じられないときは何もしない(読むだけなので害はない)。
645
+ }
646
+ }
647
+ }
648
+ }
649
+ /** 生の記録の1行を、画面に出す行(+--detail の生の行)にする。 */
650
+ linesFromRawText(raw) {
651
+ if (raw.trim().length === 0)
652
+ return [];
653
+ let parsed;
654
+ try {
655
+ parsed = JSON.parse(raw);
656
+ }
657
+ catch {
658
+ return []; // 壊れている行・書き込み途中の行は飛ばす
659
+ }
660
+ const lines = humanLinesFromRecord(parsed, this.toolNames);
661
+ if (this.deps.detail === true && lines.length > 0) {
662
+ lines.push({
663
+ at: lines[0].at,
664
+ kind: 'info',
665
+ text: ` |${maskSecrets(raw).slice(0, DETAIL_LINE_CHARS_MAX)}`,
666
+ });
667
+ }
668
+ return lines;
669
+ }
670
+ /**
671
+ * 1行を画面に出す(時刻つき・同じ文が続いたら出さない)。
672
+ *
673
+ * ★記録に時刻が無い行(職人の起動の知らせ・終わりの知らせ)は、
674
+ * 直前の行の時刻を使う。今の時刻を打つと、振り返りの行が
675
+ * 「昔の行なのに今の時刻」になって並びが狂う(実機 2026-10-02 で確認)。
676
+ */
677
+ print(line) {
678
+ if (line.text === this.prevText)
679
+ return;
680
+ this.prevText = line.text;
681
+ if (line.at !== null)
682
+ this.lastAt = line.at;
683
+ this.out(`${hhmm(line.at ?? this.lastAt ?? this.now())} ${line.text}`);
684
+ }
685
+ /** 便の終わりを出す(結末=完了/失敗と理由)。 */
686
+ printJobEnd(cur) {
687
+ const last = this.last();
688
+ this.out(RULE);
689
+ if (last !== null && last.commandId === cur.commandId) {
690
+ this.out(`── 便が終わりました(${friendlyTime(last.finishedAt, this.now())})`);
691
+ this.out(`結末:${LAST_OUTCOME_LABEL[last.outcome] ?? last.outcome}`);
692
+ if (last.detail !== undefined && last.detail.length > 0) {
693
+ this.out(`理由:${clip(maskSecrets(last.detail), 200)}`);
694
+ }
695
+ this.out(`かかった時間:${friendlyDuration(last.finishedAt - last.startedAt)}`);
696
+ }
697
+ else {
698
+ this.out('── 便が終わりました(結末がまだ記録されていません)');
699
+ this.out('※少し待つと結末が記録されます。もう一度開き直すと読めます。');
700
+ }
701
+ this.out(RULE);
702
+ }
703
+ /** 便が無いときの知らせ(★1回だけ出して、そのまま待つ)。 */
704
+ noteWaiting() {
705
+ if (this.waitingNoted)
706
+ return;
707
+ this.waitingNoted = true;
708
+ this.out('⏳ 待機中(いまは便がありません。次の便を待っています)');
709
+ this.out('※このまま開いておけば、次の便が始まったときに自動で流れ始めます');
710
+ }
711
+ }
712
+ // ─────────────────────────────────────────────────────────────────────────────
713
+ // 入口(view.ts が呼ぶ)
714
+ // ─────────────────────────────────────────────────────────────────────────────
715
+ /**
716
+ * 開いたまま流れ続ける画面を動かす。Ctrl+C(SIGINT)で抜ける。
717
+ *
718
+ * ★抜けても職人・見守り役には何も起きない(読むだけなので、やめるのも読むのをやめるだけ)。
719
+ */
720
+ export async function runFollowCommand(site, detail) {
721
+ const session = new FollowSession({
722
+ site,
723
+ detail,
724
+ out: (line) => {
725
+ console.log(line);
726
+ },
727
+ });
728
+ session.open();
729
+ let stop = false;
730
+ const onStop = () => {
731
+ stop = true;
732
+ };
733
+ process.on('SIGINT', onStop);
734
+ process.on('SIGTERM', onStop);
735
+ try {
736
+ while (!stop) {
737
+ await new Promise((resolve) => {
738
+ setTimeout(resolve, FOLLOW_POLL_MS);
739
+ });
740
+ if (stop)
741
+ break;
742
+ session.poll();
743
+ }
744
+ }
745
+ finally {
746
+ process.off('SIGINT', onStop);
747
+ process.off('SIGTERM', onStop);
748
+ }
749
+ console.log('');
750
+ console.log('画面を閉じました(職人はそのまま動いています)');
751
+ return 0;
752
+ }
753
+ /** ファイルの末尾だけを読む(★全部は読まない)。読めなければ null。 */
754
+ function readTail(p, maxBytes) {
755
+ let fd = null;
756
+ try {
757
+ if (!existsSync(p))
758
+ return null;
759
+ const size = statSync(p).size;
760
+ if (size === 0)
761
+ return { text: '', size, startedMidFile: false };
762
+ const from = size > maxBytes ? size - maxBytes : 0;
763
+ const len = size - from;
764
+ fd = openSync(p, 'r');
765
+ const buf = Buffer.alloc(len);
766
+ const got = readSync(fd, buf, 0, len, from);
767
+ return { text: buf.subarray(0, got).toString('utf8'), size, startedMidFile: from > 0 };
768
+ }
769
+ catch {
770
+ return null;
771
+ }
772
+ finally {
773
+ if (fd !== null) {
774
+ try {
775
+ closeSync(fd);
776
+ }
777
+ catch {
778
+ // 読むだけなので、閉じられなくても害はない。
779
+ }
780
+ }
781
+ }
782
+ }
package/dist/view.js CHANGED
@@ -8,6 +8,8 @@
8
8
  * =「見えない」だけでなく「触ると危ない」状態だった。
9
9
  *
10
10
  * 【この画面の約束(えふさん確定 2026-10-02)】
11
+ * ⓪ 既定は「開いたまま流れ続ける」(判断7・便 a56e49bf)。流れ続ける部分は
12
+ * src/view-stream.ts に置く。このファイルは「いまの様子を1回出す」(--once)形を持つ。
11
13
  * ① 通常=人間向けの日本語/詳細=Claude Code の生ログ の2段構成。
12
14
  * 以前の画面をそのまま流すのではなく、非エンジニアが読んで分かる形にする。
13
15
  * ② 「🟠 作業中」と出してよいのは、実プロセスが動いている + 最近の作業記録がある
@@ -472,27 +474,40 @@ export function readRawLogLines(v) {
472
474
  // 入口(cli.ts が呼ぶ)
473
475
  // ─────────────────────────────────────────────────────────────────────────────
474
476
  /**
475
- * `conductor view <現場名> [--detail]` の本体。
476
- * ★何も起動しない・通信しない・入力を受け付けない。画面に出して終わる。
477
+ * `conductor view <現場名> [--once] [--detail]` の本体。
478
+ * ★何も起動しない・通信しない・入力を受け付けない。
479
+ *
480
+ * 【既定は「開いたまま流れ続ける」(えふさん確定 2026-10-02・判断7)】
481
+ * 画面あり方式の頃のように、開いておけば職人の動きが下へ流れ続ける。
482
+ * Ctrl+C で抜ける(★抜けても職人・見守り役には何も起きない)。
483
+ * 【--once】これまでどおり「いまの様子を1回出して終わる」(古い呼び方を壊さない)。
484
+ * 【--detail】生の記録(秘密は隠したもの)も続けて出す。意味は変えない。
477
485
  */
478
- export function runViewCommand(argv) {
486
+ export async function runViewCommand(argv) {
479
487
  const detail = argv.includes('--detail');
488
+ const once = argv.includes('--once');
480
489
  const site = argv.find((a) => !a.startsWith('-'));
481
490
  if (site === undefined || site.length === 0) {
482
- console.error('使い方: conductor view <現場名> [--detail]');
491
+ console.error('使い方: conductor view <現場名> [--once] [--detail]');
483
492
  return 2;
484
493
  }
485
- // ★現場のフォルダが無い=この PC の現場ではない。何も作らずに知らせる。
494
+ // ★現場のフォルダが無い=この PC の現場ではない。何も作らずに、次の1手を添えて知らせる。
486
495
  const projectDir = path.join(os.homedir(), 'projects', site);
487
496
  if (!existsSync(projectDir)) {
488
497
  console.error(`そんな現場はありません: ${site}`);
498
+ console.error('次の1手:このPCの現場の名前は ls ~/projects で確かめられます(大文字小文字も合わせてください)。');
489
499
  return 1;
490
500
  }
491
- const v = collectSiteView(site);
492
- console.log(renderNormalView(v));
493
- if (detail) {
494
- console.log('');
495
- console.log(renderDetailView(v, readRawLogLines(v)));
501
+ if (once) {
502
+ const v = collectSiteView(site);
503
+ console.log(renderNormalView(v));
504
+ if (detail) {
505
+ console.log('');
506
+ console.log(renderDetailView(v, readRawLogLines(v)));
507
+ }
508
+ return 0;
496
509
  }
497
- return 0;
510
+ // ★流れ続ける画面は別のファイルに置く(--once の道は1バイトも変えない)。
511
+ const { runFollowCommand } = await import('./view-stream.js');
512
+ return runFollowCommand(site, detail);
498
513
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyhld/conductor",
3
- "version": "0.12.82",
3
+ "version": "0.12.84",
4
4
  "description": "采配くん管制の見守りアプリ(conductor-agent)。各PCで常駐し、中央(devlog-tracker)へ定期的に生存報告(heartbeat)を送る常駐CLI。",
5
5
  "type": "module",
6
6
  "bin": {