openshain 0.2.0 → 0.4.0

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 (46) hide show
  1. package/NOTICE +4 -0
  2. package/dist/bin.js +4 -33
  3. package/dist/commands/init.d.ts +1 -1
  4. package/dist/commands/init.js +7 -6
  5. package/dist/commands/tools.js +1 -2
  6. package/dist/commands/work.d.ts +1 -9
  7. package/dist/commands/work.js +9 -22
  8. package/dist/index.d.ts +2 -2
  9. package/dist/index.js +2 -2
  10. package/dist/labels.d.ts +1 -2
  11. package/dist/labels.js +3 -0
  12. package/dist/preview.d.ts +13 -0
  13. package/dist/preview.js +141 -0
  14. package/dist/report.d.ts +6 -0
  15. package/dist/{commands/run.js → report.js} +6 -38
  16. package/dist/tui/app.js +38 -5
  17. package/dist/tui/banner.d.ts +3 -8
  18. package/dist/tui/banner.js +2 -2
  19. package/dist/tui/controller.d.ts +30 -5
  20. package/dist/tui/controller.js +306 -77
  21. package/dist/tui/index.js +1 -1
  22. package/dist/tui/lines.d.ts +5 -3
  23. package/dist/tui/lines.js +31 -3
  24. package/dist/tui/markdown.d.ts +15 -0
  25. package/dist/tui/markdown.js +199 -0
  26. package/dist/usage.d.ts +1 -1
  27. package/dist/usage.js +1 -1
  28. package/dist/workspace.js +1 -1
  29. package/package.json +9 -7
  30. package/src/bin.ts +4 -38
  31. package/src/commands/init.ts +7 -6
  32. package/src/commands/tools.ts +7 -2
  33. package/src/commands/work.ts +10 -34
  34. package/src/index.ts +1 -10
  35. package/src/labels.ts +4 -2
  36. package/src/preview.ts +157 -0
  37. package/src/{commands/run.ts → report.ts} +6 -59
  38. package/src/tui/app.tsx +75 -13
  39. package/src/tui/banner.ts +4 -10
  40. package/src/tui/controller.ts +338 -77
  41. package/src/tui/index.ts +3 -1
  42. package/src/tui/lines.ts +36 -6
  43. package/src/tui/markdown.ts +214 -0
  44. package/src/usage.ts +1 -2
  45. package/src/workspace.ts +1 -1
  46. package/dist/commands/run.d.ts +0 -22
@@ -1,17 +1,27 @@
1
- import { createSession, type Session, type TurnResult } from "@openshain/agent";
2
1
  import {
3
- type AnyEvent,
4
- createRuntime,
2
+ type ApprovalAnswer,
3
+ type ApprovalChoice,
4
+ connectInMemory,
5
+ createSession,
6
+ type HeldApproval,
7
+ type Session,
8
+ type TurnResult,
9
+ } from "@openshain/agent";
10
+ import {
5
11
  type Event,
6
- type Runtime,
12
+ loadConfig,
13
+ OpenshainError,
7
14
  type RuntimeProviders,
8
15
  type WorkId,
16
+ WorkStore,
9
17
  } from "@openshain/core";
10
- import { progressLine, report } from "../commands/run.ts";
18
+ import { createMcpServer } from "@openshain/mcp";
11
19
  import { toolsList } from "../commands/tools.ts";
12
- import { workList, workResume, workShow } from "../commands/work.ts";
13
- import { plain } from "../format.ts";
20
+ import { workList, workShow } from "../commands/work.ts";
21
+ import { describeInput, plain } from "../format.ts";
14
22
  import { statusLabel } from "../labels.ts";
23
+ import { type PreviewLine, previewCall } from "../preview.ts";
24
+ import { progressLine, report } from "../report.ts";
15
25
  import { LOGO_ROWS, VERSION } from "./banner.ts";
16
26
 
17
27
  /** logo and banner are the rows shown once when the screen opens: the wordmark, the version, the folder. */
@@ -40,6 +50,19 @@ export interface ControllerState {
40
50
  busy: boolean;
41
51
  /** A question a work is asking; the next line the person types answers it. */
42
52
  question?: string;
53
+ /** A call held for approval; the person picks one of its choices before the work goes on. */
54
+ approval?: {
55
+ approvalId: string;
56
+ title: string;
57
+ ruleId: string;
58
+ preview: PreviewLine[];
59
+ choices: { key: ApprovalChoice | "reject_with_reason"; label: string }[];
60
+ at: number;
61
+ };
62
+ /** After "no, and tell the agent why": the next line the person types is that reason. */
63
+ reason?: string;
64
+ /** Lines typed while the screen was busy. They are sent in order once it is free. */
65
+ queued: string[];
43
66
  closed: boolean;
44
67
  status: {
45
68
  company: string;
@@ -59,6 +82,10 @@ export interface Controller {
59
82
  submit(line: string): Promise<void>;
60
83
  /** Ctrl-C: stops the running work, taking back a question it waits on; false when nothing was running. */
61
84
  interrupt(): boolean;
85
+ /** Moves the highlight in the approval choices. */
86
+ moveApproval(delta: number): void;
87
+ /** Answers the approval being shown: the highlighted choice, or the one given. */
88
+ decideApproval(choice?: ApprovalChoice | "reject_with_reason"): void;
62
89
  /** Stops whatever is running, then ends the session. A second call waits for the same close. */
63
90
  close(): Promise<void>;
64
91
  }
@@ -66,13 +93,15 @@ export interface Controller {
66
93
  export interface ControllerOptions {
67
94
  workspaceRoot: string;
68
95
  providers: RuntimeProviders;
69
- runtime?: Runtime;
70
96
  }
71
97
 
72
98
  const HELP = [
73
99
  "/work list Work の一覧",
74
100
  "/work show <id> Work の詳細",
75
- "/work resume <id> 止まった Work を続ける",
101
+ "/work resume <id> 止まった Work を候補にする。次の依頼がそれに沿えば続ける",
102
+ "/approvals 承認待ちの一覧",
103
+ "/approve <id> 承認して実行する。/reject <id> [理由] で拒否する",
104
+ "/review <id> approve|reject 資格者の判断を記録する。名前と本文を順に聞く",
76
105
  "/tools 使える Tool",
77
106
  "/quit 終わる",
78
107
  "↑ ↓ 前に送った行を入力欄に呼び戻す。いちばん下は新しい入力",
@@ -81,24 +110,61 @@ const HELP = [
81
110
  "Ctrl-C 動いている Work を止める。質問待ちなら質問を取り下げる。何も動いていなければ終わる",
82
111
  ];
83
112
 
113
+ /** The choices the screen offers for a held call, in the order they are shown. */
114
+ const APPROVAL_CHOICES: { key: ApprovalChoice | "reject_with_reason"; label: string }[] = [
115
+ { key: "approve", label: "はい。実行する" },
116
+ { key: "always", label: "はい。この会話では同じ規則の呼び出しを常に承認する" },
117
+ { key: "reject", label: "いいえ。実行しない" },
118
+ { key: "reject_with_reason", label: "いいえ。理由を伝えて実行しない" },
119
+ ];
120
+
84
121
  /** What the session's model hears when the person stops a work that waits for their answer. */
85
122
  const QUESTION_WITHDRAWN =
86
123
  "the person stopped the work while it waited for their answer; the question is still pending and the work can be resumed";
87
124
 
88
- /** The state behind the screen: a session, the works it starts, and the lines to show. */
125
+ /** What the loop hears when the person leaves a held call undecided. */
126
+ const APPROVAL_WITHDRAWN = "the person left the approval undecided";
127
+
128
+ /**
129
+ * The state behind the screen: a session, the works it starts, and the lines to show. The
130
+ * conversation reaches the runtime only as an MCP client of the workspace's own server, the way
131
+ * any other agent does; the records are read directly for the closing lines.
132
+ */
89
133
  export async function createController(options: ControllerOptions): Promise<Controller> {
90
- const runtime =
91
- options.runtime ??
92
- (await createRuntime({ workspaceRoot: options.workspaceRoot, providers: options.providers }));
134
+ const { workspaceRoot, providers } = options;
135
+ const config = await loadConfig(workspaceRoot, { modelProviders: Object.keys(providers.models) });
136
+ if (!config.model) {
137
+ throw new OpenshainError(
138
+ "config",
139
+ "対話にはモデルが要ります。openshain.yaml に model を書いてください。Claude Code や Codex から使うだけなら要りません。",
140
+ );
141
+ }
142
+ const modelFactory = Object.hasOwn(providers.models, config.model.provider)
143
+ ? providers.models[config.model.provider]
144
+ : undefined;
145
+ if (!modelFactory) {
146
+ throw new OpenshainError("config", `unknown model provider "${config.model.provider}"`);
147
+ }
148
+ const model = modelFactory(config.model);
149
+ if (!model.describe().capabilities.tools) {
150
+ throw new OpenshainError(
151
+ "config",
152
+ `model ${config.model.provider}/${config.model.model} cannot call tools; openshain needs a model with tool support`,
153
+ );
154
+ }
155
+ const server = await createMcpServer({ workspaceRoot, tools: providers.tools });
156
+ const client = await connectInMemory(server);
157
+ const store = new WorkStore(workspaceRoot);
93
158
  const listeners = new Set<() => void>();
94
159
  let nextId = 1;
95
160
  const state: ControllerState = {
96
161
  entries: [],
97
162
  busy: false,
98
163
  closed: false,
164
+ queued: [],
99
165
  status: {
100
- company: runtime.config.company.name,
101
- model: `${runtime.config.model.provider}/${runtime.config.model.model}`,
166
+ company: config.company.name,
167
+ model: `${config.model.provider}/${config.model.model}`,
102
168
  usage: { modelCalls: 0, inputTokens: 0, outputTokens: 0 },
103
169
  },
104
170
  };
@@ -126,13 +192,23 @@ export async function createController(options: ControllerOptions): Promise<Cont
126
192
  };
127
193
 
128
194
  let pending: { resolve: (text: string) => void; reject: (reason: Error) => void } | undefined;
195
+ let deciding:
196
+ | { resolve: (answer: ApprovalAnswer) => void; reject: (reason: Error) => void }
197
+ | undefined;
129
198
  let aborter: AbortController | undefined;
130
199
  let running: Promise<void> | undefined;
131
200
  let closing: Promise<void> | undefined;
132
- /** The child work of the current turn while it is unfinished. */
133
- let lastWorkId: WorkId | undefined;
134
201
  const names = new Map<string, string>();
135
202
 
203
+ /** Asks the person for one line and waits for it. The next line they type is the answer. */
204
+ const askLine = (question: string): Promise<string> => {
205
+ state.question = question;
206
+ push("question", question);
207
+ notify();
208
+ return new Promise((resolve, reject) => {
209
+ pending = { resolve, reject };
210
+ });
211
+ };
136
212
  const ask = (workId: WorkId, question: string): Promise<string> => {
137
213
  state.question = question;
138
214
  push("question", `${question}(${workId})`);
@@ -140,6 +216,51 @@ export async function createController(options: ControllerOptions): Promise<Cont
140
216
  pending = { resolve, reject };
141
217
  });
142
218
  };
219
+ /** Shows a held call and waits for the person to pick one of the choices. */
220
+ const askApproval = async (approval: HeldApproval): Promise<ApprovalAnswer> => {
221
+ const preview = await previewCall(workspaceRoot, {
222
+ name: approval.name,
223
+ input: approval.input,
224
+ }).catch((err) => [{ kind: "note", text: message(err) } as PreviewLine]);
225
+ const title = `${approval.name} ${describeInput(approval.input)}`.trimEnd();
226
+ state.approval = {
227
+ approvalId: approval.approvalId,
228
+ title,
229
+ ruleId: approval.ruleId,
230
+ preview,
231
+ choices: APPROVAL_CHOICES,
232
+ at: 0,
233
+ };
234
+ push("question", `承認が要ります: ${title}`);
235
+ for (const line of preview) {
236
+ push(
237
+ "progress",
238
+ `${line.kind === "added" ? "+ " : line.kind === "removed" ? "- " : " "}${line.text}`,
239
+ );
240
+ }
241
+ return new Promise<ApprovalAnswer>((resolve, reject) => {
242
+ deciding = { resolve, reject };
243
+ });
244
+ };
245
+ /** Settles the approval being shown, or takes it back when there is no choice. */
246
+ const settleApproval = (choice?: ApprovalChoice, comment?: string) => {
247
+ const waiting = deciding;
248
+ deciding = undefined;
249
+ if (state.reason !== undefined) {
250
+ delete state.reason;
251
+ notify();
252
+ }
253
+ if (state.approval !== undefined) {
254
+ const decided = choice ? APPROVAL_CHOICES.find((c) => c.key === choice)?.label : undefined;
255
+ delete state.approval;
256
+ if (decided) push("line", `> ${decided}`);
257
+ notify();
258
+ }
259
+ if (!waiting) return;
260
+ if (choice === undefined) waiting.reject(new Error(APPROVAL_WITHDRAWN));
261
+ else waiting.resolve({ choice, ...(comment !== undefined && comment !== "" && { comment }) });
262
+ };
263
+
143
264
  /** Answers the pending question, or takes it back when there is no answer. */
144
265
  const settleQuestion = (answer?: string) => {
145
266
  const waiting = pending;
@@ -155,66 +276,98 @@ export async function createController(options: ControllerOptions): Promise<Cont
155
276
 
156
277
  /** The lines the CLI prints when a work ends, shown among the progress lines. */
157
278
  const closingLines = async (workId: WorkId) => {
158
- for (const line of await workReport(runtime, workId)) push("progress", line.trimStart());
159
- };
160
-
161
- const onWorkEvent = (workId: WorkId, event: AnyEvent): void | Promise<void> => {
162
- lastWorkId = workId;
163
- if (event.type === "work.status_changed") {
164
- state.status.work = {
165
- id: workId,
166
- status: (event as Event<"work.status_changed">).payload.to,
167
- };
168
- } else if (event.type === "work.completed" || event.type === "work.failed") {
169
- lastWorkId = undefined;
170
- state.status.work = {
171
- id: workId,
172
- status: event.type === "work.completed" ? "completed" : "failed",
173
- };
174
- return closingLines(workId);
175
- }
176
- const line = progressLine(event, names);
177
- if (line) push("progress", line);
178
- else notify();
279
+ for (const line of await workReport(store, workId)) push("progress", line.trimStart());
179
280
  };
180
281
 
181
- const session: Session = await createSession(runtime, {
182
- onEvent: (event) => {
183
- if (event.type === "usage.recorded") {
184
- const { payload } = event as Event<"usage.recorded">;
185
- if (payload.kind === "model_inference") {
186
- state.status.usage.modelCalls += 1;
187
- state.status.usage.inputTokens += payload.usage.inputTokens;
188
- state.status.usage.outputTokens += payload.usage.outputTokens;
189
- notify();
282
+ let sessionId: WorkId | undefined;
283
+ /** Summaries of the works completed in this turn, until the agent reports them itself. */
284
+ let unreported: string[] = [];
285
+ const session: Session = await createSession(client, {
286
+ model,
287
+ config,
288
+ onEvent: (workId, event) => {
289
+ if (workId === sessionId) {
290
+ if (event.type === "usage.recorded") {
291
+ const { payload } = event as Event<"usage.recorded">;
292
+ if (payload.kind === "model_inference") {
293
+ state.status.usage.modelCalls += 1;
294
+ state.status.usage.inputTokens += payload.usage.inputTokens;
295
+ state.status.usage.outputTokens += payload.usage.outputTokens;
296
+ notify();
297
+ }
190
298
  }
299
+ return;
300
+ }
301
+ if (event.type === "work.status_changed") {
302
+ state.status.work = {
303
+ id: workId,
304
+ status: (event as Event<"work.status_changed">).payload.to,
305
+ };
306
+ notify();
307
+ return;
191
308
  }
309
+ if (event.type === "work.completed" || event.type === "work.failed") {
310
+ state.status.work = {
311
+ id: workId,
312
+ status: event.type === "work.completed" ? "completed" : "failed",
313
+ };
314
+ if (event.type === "work.completed") {
315
+ // Held, not shown: the agent is the one who tells the person what happened. It is
316
+ // shown only if the turn ends without the agent saying anything (see submit).
317
+ const { summary } = (event as Event<"work.completed">).payload;
318
+ if (summary.trim() !== "") unreported.push(summary.trim());
319
+ }
320
+ return closingLines(workId);
321
+ }
322
+ // The work_* calls are the loop's own bookkeeping; the closing lines already say the work ended.
323
+ if (
324
+ event.type === "tool.called" &&
325
+ (event as Event<"tool.called">).payload.name.startsWith("work_")
326
+ ) {
327
+ names.set(
328
+ (event as Event<"tool.called">).payload.callId,
329
+ (event as Event<"tool.called">).payload.name,
330
+ );
331
+ return;
332
+ }
333
+ const line = progressLine(event, names);
334
+ if (line) push("progress", line);
335
+ else notify();
192
336
  },
193
- onWorkEvent,
194
337
  onInput: ask,
338
+ onApproval: askApproval,
195
339
  });
340
+ sessionId = session.id;
196
341
  state.status.agentName = session.agentName;
197
342
  for (const row of LOGO_ROWS) push("logo", row);
198
343
  push("banner", `openshain ${VERSION}`);
199
- push("banner", runtime.workspaceRoot);
344
+ push("banner", workspaceRoot);
200
345
 
201
- const stopped = (workId: string | undefined) =>
346
+ const stopped = (workId: WorkId | undefined) =>
202
347
  workId
203
- ? `止めました。${workId} は途中のまま残っています。/work resume ${workId} で続けられます。`
348
+ ? `止めました。${workId} は途中のまま残っています。/work resume ${workId} で続けられるようにします。`
204
349
  : "止めました。";
205
350
 
206
351
  const explain = (result: TurnResult) => {
207
352
  switch (result.stopped) {
208
353
  case "turn_limit":
209
- return "社員エージェントが 1 回の返答でできる回数を超えたので、ここで止めました。続きを依頼できます。";
354
+ return "社員エージェントが 1 回の返答でできる回数を超えたので、ここで止めました。続きは改めて依頼してください。";
210
355
  case "aborted":
211
- return stopped(lastWorkId);
356
+ return stopped(result.work);
212
357
  case "max_tokens":
213
358
  return "返答が長さの上限で切れました。";
214
359
  case "refusal":
215
360
  return "社員エージェントが続けられないと言っています。";
216
361
  case "model_error":
217
362
  return `model の呼び出しに失敗しました。${result.detail ?? ""}`.trim();
363
+ case "approval": {
364
+ const a = result.approval;
365
+ if (!a) return "承認が要ります。/approvals で確かめてください。";
366
+ if (a.kind === "review") {
367
+ return `${a.reviewer?.role ?? "資格者"}の判断が要ります: ${a.name} ${describeInput(a.input)}(${a.approvalId})。Review Package は work/${a.workId}/review/ にあります。返答が届いたら /review ${a.approvalId} approve か /review ${a.approvalId} reject で記録します。`;
368
+ }
369
+ return `承認が要ります: ${a.name} ${describeInput(a.input)}(${a.approvalId})。/approve ${a.approvalId} で実行、/reject ${a.approvalId} で拒否します。`;
370
+ }
218
371
  default:
219
372
  return undefined;
220
373
  }
@@ -239,6 +392,16 @@ export async function createController(options: ControllerOptions): Promise<Cont
239
392
  }
240
393
  };
241
394
 
395
+ /** Sends what the person typed while the agent was working, oldest first. */
396
+ const drainQueue = async () => {
397
+ while (state.queued.length > 0 && !closing) {
398
+ const [next, ...rest] = state.queued as [string, ...string[]];
399
+ state.queued = rest;
400
+ notify();
401
+ await self.submit(next);
402
+ }
403
+ };
404
+
242
405
  const capture = async (fn: (write: (line: string) => void) => Promise<unknown>) => {
243
406
  try {
244
407
  await fn((line) => push("line", line));
@@ -261,21 +424,69 @@ export async function createController(options: ControllerOptions): Promise<Cont
261
424
  else if (name === "work" && sub === "show" && id)
262
425
  await capture((write) => workShow({ workspaceRoot: options.workspaceRoot, id, write }));
263
426
  else if (name === "work" && sub === "resume" && id) {
264
- await stoppable(async (signal) => {
265
- try {
266
- await workResume({
267
- workspaceRoot: options.workspaceRoot,
268
- providers: options.providers,
269
- id,
270
- signal,
271
- write: (text) => push("line", text),
272
- ask: (q) => ask(id as WorkId, q),
273
- });
274
- } catch (err) {
275
- if (!signal.aborted) push("notice", message(err));
427
+ try {
428
+ const work = await session.select(id as WorkId);
429
+ push(
430
+ "notice",
431
+ `${work.id}(${statusLabel(work.status)}、${work.objective})を候補にしました。次の依頼がこの Work に沿えば続けます。`,
432
+ );
433
+ } catch (err) {
434
+ push("notice", message(err));
435
+ }
436
+ } else if (name === "approvals") {
437
+ try {
438
+ const held = await session.approvals();
439
+ if (held.length === 0) push("line", "承認待ちはありません。");
440
+ for (const a of held) {
441
+ push("line", `${a.approvalId} ${a.name} ${describeInput(a.input)} (${a.workId})`);
276
442
  }
277
- if (signal.aborted) push("notice", stopped(id));
278
- });
443
+ } catch (err) {
444
+ push("notice", message(err));
445
+ }
446
+ } else if ((name === "approve" || name === "reject") && sub) {
447
+ try {
448
+ const comment = args.slice(1).join(" ");
449
+ const { text } = await session.decide(sub, name, comment || undefined);
450
+ push("line", text);
451
+ } catch (err) {
452
+ push("notice", message(err));
453
+ }
454
+ } else if (name === "approve" || name === "reject") {
455
+ push("notice", `/${name} には承認の id が要ります。/approvals で確かめてください。`);
456
+ } else if (name === "review" && sub && (args[1] === "approve" || args[1] === "reject")) {
457
+ const decision = args[1];
458
+ try {
459
+ // The rule already says which role has to decide; the person only says who they are.
460
+ const held = (await session.approvals()).find((a) => a.approvalId === sub);
461
+ if (!held)
462
+ throw new Error(`${sub} は承認待ちにありません。/approvals で確かめてください。`);
463
+ if (held.kind !== "review") {
464
+ throw new Error(`${sub} は人の承認待ちです。/approve か /reject で決めます。`);
465
+ }
466
+ const role = held.reviewer?.role ?? "reviewer";
467
+ const who = await askLine(
468
+ `${role} の名前と資格(例: 田中 太郎 / 税理士)。会社の申告として記録します`,
469
+ );
470
+ const [reviewerName, qualification] = who.split("/").map((part) => part.trim());
471
+ const interpretation = await askLine(
472
+ decision === "approve" ? "判断の本文(そのまま記録します)" : "認めない理由",
473
+ );
474
+ const { text } = await session.review({
475
+ approvalId: sub,
476
+ decision,
477
+ reviewer: {
478
+ name: reviewerName || who,
479
+ role,
480
+ ...(qualification && { qualification }),
481
+ },
482
+ interpretation,
483
+ });
484
+ push("line", text);
485
+ } catch (err) {
486
+ push("notice", message(err));
487
+ }
488
+ } else if (name === "review") {
489
+ push("notice", "/review <id> approve か /review <id> reject の形です。");
279
490
  } else if (name === "resume") {
280
491
  push(
281
492
  "notice",
@@ -290,15 +501,22 @@ export async function createController(options: ControllerOptions): Promise<Cont
290
501
  closing ??= (async () => {
291
502
  aborter?.abort();
292
503
  settleQuestion();
504
+ settleApproval();
293
505
  await running;
294
- await session.close();
295
- state.closed = true;
296
- notify();
506
+ try {
507
+ await session.close();
508
+ } catch (err) {
509
+ push("notice", `会話の記録を閉じられませんでした。${message(err)}`);
510
+ } finally {
511
+ await client.close().catch(() => undefined);
512
+ state.closed = true;
513
+ notify();
514
+ }
297
515
  })();
298
516
  return closing;
299
517
  }
300
518
 
301
- return {
519
+ const self: Controller = {
302
520
  sessionId: session.id,
303
521
  state: () => state,
304
522
  subscribe(listener) {
@@ -308,6 +526,15 @@ export async function createController(options: ControllerOptions): Promise<Cont
308
526
  async submit(line) {
309
527
  const text = line.trim();
310
528
  if (text === "" || closing) return;
529
+ if (state.approval) {
530
+ push("notice", "承認を先に決めてください。数字か ↑↓ と Enter で選びます。");
531
+ return;
532
+ }
533
+ if (state.reason !== undefined) {
534
+ push("user", text);
535
+ settleApproval("reject", text);
536
+ return;
537
+ }
311
538
  if (pending) {
312
539
  push("user", text);
313
540
  // Everything typed answers the question, except leaving: that takes the question back.
@@ -316,7 +543,10 @@ export async function createController(options: ControllerOptions): Promise<Cont
316
543
  return;
317
544
  }
318
545
  if (state.busy) {
319
- push("notice", "いま動いています。止めるなら Ctrl-C。");
546
+ // Typing while the agent works is not a mistake: the line waits its turn.
547
+ state.queued = [...state.queued, text];
548
+ push("notice", `順番待ち(${state.queued.length} 件): ${text}`);
549
+ notify();
320
550
  return;
321
551
  }
322
552
  if (text.startsWith("/")) {
@@ -325,31 +555,62 @@ export async function createController(options: ControllerOptions): Promise<Cont
325
555
  return;
326
556
  }
327
557
  push("user", text);
558
+ unreported = [];
328
559
  await stoppable(async (signal) => {
329
560
  try {
330
561
  const result = await session.turn(text, { signal });
331
- if (result.reply) push("assistant", result.reply);
562
+ // What the work recorded is the agent's own writing, so it stands in when the turn
563
+ // ends with nothing said. Without this the person is left with a work that finished
564
+ // and no answer, which is what a model that skips its summary leaves behind.
565
+ const reply = result.reply.trim() === "" ? unreported.join("\n\n") : result.reply;
566
+ if (reply) push("assistant", reply);
332
567
  const note = explain(result);
333
568
  if (note) push("notice", note);
334
569
  } catch (err) {
335
570
  push("notice", message(err));
336
571
  }
337
572
  });
573
+ await drainQueue();
338
574
  },
339
575
  interrupt() {
340
576
  if (!aborter) return false;
341
577
  aborter.abort();
342
578
  settleQuestion();
579
+ settleApproval();
343
580
  return true;
344
581
  },
582
+ moveApproval(delta) {
583
+ const approval = state.approval;
584
+ if (!approval) return;
585
+ const count = approval.choices.length;
586
+ state.approval = { ...approval, at: (approval.at + delta + count) % count };
587
+ notify();
588
+ },
589
+ decideApproval(choice) {
590
+ const approval = state.approval;
591
+ if (!approval) return;
592
+ const picked = choice ?? approval.choices[approval.at]?.key ?? "reject";
593
+ if (picked === "reject_with_reason") {
594
+ // The palette closes and the input box takes the reason; the loop still waits.
595
+ const shown = APPROVAL_CHOICES.find((c) => c.key === picked)?.label;
596
+ delete state.approval;
597
+ state.reason = "実行しない理由(社員エージェントに伝わります)";
598
+ if (shown) push("line", `> ${shown}`);
599
+ push("question", state.reason);
600
+ notify();
601
+ return;
602
+ }
603
+ settleApproval(picked);
604
+ },
345
605
  close,
346
606
  };
607
+ return self;
347
608
  }
348
609
 
349
- /** Lines that close a work in the screen: the CLI's closing lines without the summary, which the clerk relays. */
350
- export async function workReport(runtime: Runtime, workId: WorkId): Promise<string[]> {
351
- const work = await runtime.works.get(workId);
352
- const events = await runtime.works.events(workId);
610
+ /** Lines that close a work in the screen: the CLI's closing lines without the summary, which the agent relays. */
611
+ export async function workReport(store: WorkStore, workId: WorkId): Promise<string[]> {
612
+ const work = await store.get(workId);
613
+ const events = await store.events(workId);
353
614
  const lines = report(work, events);
354
615
  return work.status === "completed" ? ["完了。", ...lines.slice(1)] : lines;
355
616
  }
package/src/tui/index.ts CHANGED
@@ -46,6 +46,8 @@ export async function startTui(options: TuiOptions): Promise<number> {
46
46
  } finally {
47
47
  leave();
48
48
  }
49
- console.log(`会話を終えました。記録は openshain work show ${controller.sessionId} で読めます。`);
49
+ console.log(
50
+ `会話を終えました。記録は openshain work show ${controller.sessionId} で参照します。`,
51
+ );
50
52
  return 0;
51
53
  }
package/src/tui/lines.ts CHANGED
@@ -1,12 +1,14 @@
1
1
  import { displayWidth } from "../format.ts";
2
- import { logoSegments, type Segment } from "./banner.ts";
2
+ import { logoSegments } from "./banner.ts";
3
3
  import type { Entry, EntryKind } from "./controller.ts";
4
+ import { markdownRows, type Span } from "./markdown.ts";
4
5
 
5
6
  export interface ScreenLine {
6
7
  kind: EntryKind | "blank";
8
+ /** The row as plain characters, marker included. */
7
9
  text: string;
8
- /** Colored pieces of a logo row; the other rows are one color. */
9
- segments?: Segment[];
10
+ /** The row as styled pieces: a logo row, or a reply the screen drew from its markdown. */
11
+ spans?: Span[];
10
12
  }
11
13
 
12
14
  /** What starts a line of each kind. The continuation lines of a wrapped entry are indented to match. */
@@ -42,6 +44,20 @@ export function wrapText(text: string, width: number): string[] {
42
44
  return out;
43
45
  }
44
46
 
47
+ /**
48
+ * The rows of one reply, kept until the entry goes or the width changes. The screen redraws
49
+ * every entry whenever a line is added, and reading markdown is the expensive part of that.
50
+ */
51
+ const drawn = new WeakMap<Entry, { width: number; rows: Span[][] }>();
52
+
53
+ export function rowsFor(entry: Entry, width: number): Span[][] {
54
+ const held = drawn.get(entry);
55
+ if (held && held.width === width) return held.rows;
56
+ const rows = markdownRows(entry.text, width);
57
+ drawn.set(entry, { width, rows });
58
+ return rows;
59
+ }
60
+
45
61
  /** A blank row goes before an entry that starts something new: a message, a reply, a notice, a question. */
46
62
  function startsBlock(kind: EntryKind, previous: EntryKind | undefined): boolean {
47
63
  if (previous === undefined) return false;
@@ -59,14 +75,28 @@ export function screenLines(entries: readonly Entry[], width: number): ScreenLin
59
75
  if (startsBlock(entry.kind, previous)) lines.push({ kind: "blank", text: "" });
60
76
  if (entry.kind === "logo") {
61
77
  // Never wrapped: a cut row of the wordmark reads better than a broken one.
62
- lines.push({ kind: "logo", text: entry.text, segments: logoSegments(entry.text) });
78
+ lines.push({ kind: "logo", text: entry.text, spans: logoSegments(entry.text) });
63
79
  previous = entry.kind;
64
80
  continue;
65
81
  }
66
82
  const marker = MARKERS[entry.kind];
67
83
  const indent = " ".repeat(displayWidth(marker));
68
- const body = wrapText(entry.text, Math.max(8, width - displayWidth(marker)));
69
- for (const [i, text] of body.entries()) {
84
+ const room = Math.max(8, width - displayWidth(marker));
85
+ if (entry.kind === "assistant") {
86
+ // The reply is written in markdown; the screen draws it rather than showing its marks.
87
+ for (const [i, row] of rowsFor(entry, room).entries()) {
88
+ // A row with nothing on it is drawn as an empty one: no marker, no indent, no pieces.
89
+ if (row.length === 0) {
90
+ lines.push({ kind: entry.kind, text: "" });
91
+ continue;
92
+ }
93
+ const spans = [{ text: i === 0 ? marker : indent }, ...row];
94
+ lines.push({ kind: entry.kind, text: spans.map((s) => s.text).join(""), spans });
95
+ }
96
+ previous = entry.kind;
97
+ continue;
98
+ }
99
+ for (const [i, text] of wrapText(entry.text, room).entries()) {
70
100
  lines.push({ kind: entry.kind, text: (i === 0 ? marker : indent) + text });
71
101
  }
72
102
  previous = entry.kind;