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,5 +1,7 @@
1
- import { type Runtime, type RuntimeProviders, type WorkId } from "@openshain/core";
1
+ import { type ApprovalChoice } from "@openshain/agent";
2
+ import { type RuntimeProviders, type WorkId, WorkStore } from "@openshain/core";
2
3
  import { statusLabel } from "../labels.ts";
4
+ import { type PreviewLine } from "../preview.ts";
3
5
  /** logo and banner are the rows shown once when the screen opens: the wordmark, the version, the folder. */
4
6
  export type EntryKind = "user" | "assistant" | "progress" | "notice" | "question" | "line" | "logo" | "banner";
5
7
  export interface Entry {
@@ -16,6 +18,22 @@ export interface ControllerState {
16
18
  busy: boolean;
17
19
  /** A question a work is asking; the next line the person types answers it. */
18
20
  question?: string;
21
+ /** A call held for approval; the person picks one of its choices before the work goes on. */
22
+ approval?: {
23
+ approvalId: string;
24
+ title: string;
25
+ ruleId: string;
26
+ preview: PreviewLine[];
27
+ choices: {
28
+ key: ApprovalChoice | "reject_with_reason";
29
+ label: string;
30
+ }[];
31
+ at: number;
32
+ };
33
+ /** After "no, and tell the agent why": the next line the person types is that reason. */
34
+ reason?: string;
35
+ /** Lines typed while the screen was busy. They are sent in order once it is free. */
36
+ queued: string[];
19
37
  closed: boolean;
20
38
  status: {
21
39
  company: string;
@@ -41,16 +59,23 @@ export interface Controller {
41
59
  submit(line: string): Promise<void>;
42
60
  /** Ctrl-C: stops the running work, taking back a question it waits on; false when nothing was running. */
43
61
  interrupt(): boolean;
62
+ /** Moves the highlight in the approval choices. */
63
+ moveApproval(delta: number): void;
64
+ /** Answers the approval being shown: the highlighted choice, or the one given. */
65
+ decideApproval(choice?: ApprovalChoice | "reject_with_reason"): void;
44
66
  /** Stops whatever is running, then ends the session. A second call waits for the same close. */
45
67
  close(): Promise<void>;
46
68
  }
47
69
  export interface ControllerOptions {
48
70
  workspaceRoot: string;
49
71
  providers: RuntimeProviders;
50
- runtime?: Runtime;
51
72
  }
52
- /** The state behind the screen: a session, the works it starts, and the lines to show. */
73
+ /**
74
+ * The state behind the screen: a session, the works it starts, and the lines to show. The
75
+ * conversation reaches the runtime only as an MCP client of the workspace's own server, the way
76
+ * any other agent does; the records are read directly for the closing lines.
77
+ */
53
78
  export declare function createController(options: ControllerOptions): Promise<Controller>;
54
- /** Lines that close a work in the screen: the CLI's closing lines without the summary, which the clerk relays. */
55
- export declare function workReport(runtime: Runtime, workId: WorkId): Promise<string[]>;
79
+ /** Lines that close a work in the screen: the CLI's closing lines without the summary, which the agent relays. */
80
+ export declare function workReport(store: WorkStore, workId: WorkId): Promise<string[]>;
56
81
  export { statusLabel };
@@ -1,15 +1,20 @@
1
- import { createSession } from "@openshain/agent";
2
- import { createRuntime, } from "@openshain/core";
3
- import { progressLine, report } from "../commands/run.js";
1
+ import { connectInMemory, createSession, } from "@openshain/agent";
2
+ import { loadConfig, OpenshainError, WorkStore, } from "@openshain/core";
3
+ import { createMcpServer } from "@openshain/mcp";
4
4
  import { toolsList } from "../commands/tools.js";
5
- import { workList, workResume, workShow } from "../commands/work.js";
6
- import { plain } from "../format.js";
5
+ import { workList, workShow } from "../commands/work.js";
6
+ import { describeInput, plain } from "../format.js";
7
7
  import { statusLabel } from "../labels.js";
8
+ import { previewCall } from "../preview.js";
9
+ import { progressLine, report } from "../report.js";
8
10
  import { LOGO_ROWS, VERSION } from "./banner.js";
9
11
  const HELP = [
10
12
  "/work list Work の一覧",
11
13
  "/work show <id> Work の詳細",
12
- "/work resume <id> 止まった Work を続ける",
14
+ "/work resume <id> 止まった Work を候補にする。次の依頼がそれに沿えば続ける",
15
+ "/approvals 承認待ちの一覧",
16
+ "/approve <id> 承認して実行する。/reject <id> [理由] で拒否する",
17
+ "/review <id> approve|reject 資格者の判断を記録する。名前と本文を順に聞く",
13
18
  "/tools 使える Tool",
14
19
  "/quit 終わる",
15
20
  "↑ ↓ 前に送った行を入力欄に呼び戻す。いちばん下は新しい入力",
@@ -17,21 +22,51 @@ const HELP = [
17
22
  "ホイール、PageUp/PageDown 会話を遡る。送ると最新に戻る",
18
23
  "Ctrl-C 動いている Work を止める。質問待ちなら質問を取り下げる。何も動いていなければ終わる",
19
24
  ];
25
+ /** The choices the screen offers for a held call, in the order they are shown. */
26
+ const APPROVAL_CHOICES = [
27
+ { key: "approve", label: "はい。実行する" },
28
+ { key: "always", label: "はい。この会話では同じ規則の呼び出しを常に承認する" },
29
+ { key: "reject", label: "いいえ。実行しない" },
30
+ { key: "reject_with_reason", label: "いいえ。理由を伝えて実行しない" },
31
+ ];
20
32
  /** What the session's model hears when the person stops a work that waits for their answer. */
21
33
  const QUESTION_WITHDRAWN = "the person stopped the work while it waited for their answer; the question is still pending and the work can be resumed";
22
- /** The state behind the screen: a session, the works it starts, and the lines to show. */
34
+ /** What the loop hears when the person leaves a held call undecided. */
35
+ const APPROVAL_WITHDRAWN = "the person left the approval undecided";
36
+ /**
37
+ * The state behind the screen: a session, the works it starts, and the lines to show. The
38
+ * conversation reaches the runtime only as an MCP client of the workspace's own server, the way
39
+ * any other agent does; the records are read directly for the closing lines.
40
+ */
23
41
  export async function createController(options) {
24
- const runtime = options.runtime ??
25
- (await createRuntime({ workspaceRoot: options.workspaceRoot, providers: options.providers }));
42
+ const { workspaceRoot, providers } = options;
43
+ const config = await loadConfig(workspaceRoot, { modelProviders: Object.keys(providers.models) });
44
+ if (!config.model) {
45
+ throw new OpenshainError("config", "対話にはモデルが要ります。openshain.yaml に model を書いてください。Claude Code や Codex から使うだけなら要りません。");
46
+ }
47
+ const modelFactory = Object.hasOwn(providers.models, config.model.provider)
48
+ ? providers.models[config.model.provider]
49
+ : undefined;
50
+ if (!modelFactory) {
51
+ throw new OpenshainError("config", `unknown model provider "${config.model.provider}"`);
52
+ }
53
+ const model = modelFactory(config.model);
54
+ if (!model.describe().capabilities.tools) {
55
+ throw new OpenshainError("config", `model ${config.model.provider}/${config.model.model} cannot call tools; openshain needs a model with tool support`);
56
+ }
57
+ const server = await createMcpServer({ workspaceRoot, tools: providers.tools });
58
+ const client = await connectInMemory(server);
59
+ const store = new WorkStore(workspaceRoot);
26
60
  const listeners = new Set();
27
61
  let nextId = 1;
28
62
  const state = {
29
63
  entries: [],
30
64
  busy: false,
31
65
  closed: false,
66
+ queued: [],
32
67
  status: {
33
- company: runtime.config.company.name,
34
- model: `${runtime.config.model.provider}/${runtime.config.model.model}`,
68
+ company: config.company.name,
69
+ model: `${config.model.provider}/${config.model.model}`,
35
70
  usage: { modelCalls: 0, inputTokens: 0, outputTokens: 0 },
36
71
  },
37
72
  };
@@ -60,12 +95,20 @@ export async function createController(options) {
60
95
  notify();
61
96
  };
62
97
  let pending;
98
+ let deciding;
63
99
  let aborter;
64
100
  let running;
65
101
  let closing;
66
- /** The child work of the current turn while it is unfinished. */
67
- let lastWorkId;
68
102
  const names = new Map();
103
+ /** Asks the person for one line and waits for it. The next line they type is the answer. */
104
+ const askLine = (question) => {
105
+ state.question = question;
106
+ push("question", question);
107
+ notify();
108
+ return new Promise((resolve, reject) => {
109
+ pending = { resolve, reject };
110
+ });
111
+ };
69
112
  const ask = (workId, question) => {
70
113
  state.question = question;
71
114
  push("question", `${question}(${workId})`);
@@ -73,6 +116,51 @@ export async function createController(options) {
73
116
  pending = { resolve, reject };
74
117
  });
75
118
  };
119
+ /** Shows a held call and waits for the person to pick one of the choices. */
120
+ const askApproval = async (approval) => {
121
+ const preview = await previewCall(workspaceRoot, {
122
+ name: approval.name,
123
+ input: approval.input,
124
+ }).catch((err) => [{ kind: "note", text: message(err) }]);
125
+ const title = `${approval.name} ${describeInput(approval.input)}`.trimEnd();
126
+ state.approval = {
127
+ approvalId: approval.approvalId,
128
+ title,
129
+ ruleId: approval.ruleId,
130
+ preview,
131
+ choices: APPROVAL_CHOICES,
132
+ at: 0,
133
+ };
134
+ push("question", `承認が要ります: ${title}`);
135
+ for (const line of preview) {
136
+ push("progress", `${line.kind === "added" ? "+ " : line.kind === "removed" ? "- " : " "}${line.text}`);
137
+ }
138
+ return new Promise((resolve, reject) => {
139
+ deciding = { resolve, reject };
140
+ });
141
+ };
142
+ /** Settles the approval being shown, or takes it back when there is no choice. */
143
+ const settleApproval = (choice, comment) => {
144
+ const waiting = deciding;
145
+ deciding = undefined;
146
+ if (state.reason !== undefined) {
147
+ delete state.reason;
148
+ notify();
149
+ }
150
+ if (state.approval !== undefined) {
151
+ const decided = choice ? APPROVAL_CHOICES.find((c) => c.key === choice)?.label : undefined;
152
+ delete state.approval;
153
+ if (decided)
154
+ push("line", `> ${decided}`);
155
+ notify();
156
+ }
157
+ if (!waiting)
158
+ return;
159
+ if (choice === undefined)
160
+ waiting.reject(new Error(APPROVAL_WITHDRAWN));
161
+ else
162
+ waiting.resolve({ choice, ...(comment !== undefined && comment !== "" && { comment }) });
163
+ };
76
164
  /** Answers the pending question, or takes it back when there is no answer. */
77
165
  const settleQuestion = (answer) => {
78
166
  const waiting = pending;
@@ -90,66 +178,95 @@ export async function createController(options) {
90
178
  };
91
179
  /** The lines the CLI prints when a work ends, shown among the progress lines. */
92
180
  const closingLines = async (workId) => {
93
- for (const line of await workReport(runtime, workId))
181
+ for (const line of await workReport(store, workId))
94
182
  push("progress", line.trimStart());
95
183
  };
96
- const onWorkEvent = (workId, event) => {
97
- lastWorkId = workId;
98
- if (event.type === "work.status_changed") {
99
- state.status.work = {
100
- id: workId,
101
- status: event.payload.to,
102
- };
103
- }
104
- else if (event.type === "work.completed" || event.type === "work.failed") {
105
- lastWorkId = undefined;
106
- state.status.work = {
107
- id: workId,
108
- status: event.type === "work.completed" ? "completed" : "failed",
109
- };
110
- return closingLines(workId);
111
- }
112
- const line = progressLine(event, names);
113
- if (line)
114
- push("progress", line);
115
- else
116
- notify();
117
- };
118
- const session = await createSession(runtime, {
119
- onEvent: (event) => {
120
- if (event.type === "usage.recorded") {
121
- const { payload } = event;
122
- if (payload.kind === "model_inference") {
123
- state.status.usage.modelCalls += 1;
124
- state.status.usage.inputTokens += payload.usage.inputTokens;
125
- state.status.usage.outputTokens += payload.usage.outputTokens;
126
- notify();
184
+ let sessionId;
185
+ /** Summaries of the works completed in this turn, until the agent reports them itself. */
186
+ let unreported = [];
187
+ const session = await createSession(client, {
188
+ model,
189
+ config,
190
+ onEvent: (workId, event) => {
191
+ if (workId === sessionId) {
192
+ if (event.type === "usage.recorded") {
193
+ const { payload } = event;
194
+ if (payload.kind === "model_inference") {
195
+ state.status.usage.modelCalls += 1;
196
+ state.status.usage.inputTokens += payload.usage.inputTokens;
197
+ state.status.usage.outputTokens += payload.usage.outputTokens;
198
+ notify();
199
+ }
127
200
  }
201
+ return;
202
+ }
203
+ if (event.type === "work.status_changed") {
204
+ state.status.work = {
205
+ id: workId,
206
+ status: event.payload.to,
207
+ };
208
+ notify();
209
+ return;
210
+ }
211
+ if (event.type === "work.completed" || event.type === "work.failed") {
212
+ state.status.work = {
213
+ id: workId,
214
+ status: event.type === "work.completed" ? "completed" : "failed",
215
+ };
216
+ if (event.type === "work.completed") {
217
+ // Held, not shown: the agent is the one who tells the person what happened. It is
218
+ // shown only if the turn ends without the agent saying anything (see submit).
219
+ const { summary } = event.payload;
220
+ if (summary.trim() !== "")
221
+ unreported.push(summary.trim());
222
+ }
223
+ return closingLines(workId);
128
224
  }
225
+ // The work_* calls are the loop's own bookkeeping; the closing lines already say the work ended.
226
+ if (event.type === "tool.called" &&
227
+ event.payload.name.startsWith("work_")) {
228
+ names.set(event.payload.callId, event.payload.name);
229
+ return;
230
+ }
231
+ const line = progressLine(event, names);
232
+ if (line)
233
+ push("progress", line);
234
+ else
235
+ notify();
129
236
  },
130
- onWorkEvent,
131
237
  onInput: ask,
238
+ onApproval: askApproval,
132
239
  });
240
+ sessionId = session.id;
133
241
  state.status.agentName = session.agentName;
134
242
  for (const row of LOGO_ROWS)
135
243
  push("logo", row);
136
244
  push("banner", `openshain ${VERSION}`);
137
- push("banner", runtime.workspaceRoot);
245
+ push("banner", workspaceRoot);
138
246
  const stopped = (workId) => workId
139
- ? `止めました。${workId} は途中のまま残っています。/work resume ${workId} で続けられます。`
247
+ ? `止めました。${workId} は途中のまま残っています。/work resume ${workId} で続けられるようにします。`
140
248
  : "止めました。";
141
249
  const explain = (result) => {
142
250
  switch (result.stopped) {
143
251
  case "turn_limit":
144
- return "社員エージェントが 1 回の返答でできる回数を超えたので、ここで止めました。続きを依頼できます。";
252
+ return "社員エージェントが 1 回の返答でできる回数を超えたので、ここで止めました。続きは改めて依頼してください。";
145
253
  case "aborted":
146
- return stopped(lastWorkId);
254
+ return stopped(result.work);
147
255
  case "max_tokens":
148
256
  return "返答が長さの上限で切れました。";
149
257
  case "refusal":
150
258
  return "社員エージェントが続けられないと言っています。";
151
259
  case "model_error":
152
260
  return `model の呼び出しに失敗しました。${result.detail ?? ""}`.trim();
261
+ case "approval": {
262
+ const a = result.approval;
263
+ if (!a)
264
+ return "承認が要ります。/approvals で確かめてください。";
265
+ if (a.kind === "review") {
266
+ 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 で記録します。`;
267
+ }
268
+ return `承認が要ります: ${a.name} ${describeInput(a.input)}(${a.approvalId})。/approve ${a.approvalId} で実行、/reject ${a.approvalId} で拒否します。`;
269
+ }
153
270
  default:
154
271
  return undefined;
155
272
  }
@@ -172,6 +289,15 @@ export async function createController(options) {
172
289
  notify();
173
290
  }
174
291
  };
292
+ /** Sends what the person typed while the agent was working, oldest first. */
293
+ const drainQueue = async () => {
294
+ while (state.queued.length > 0 && !closing) {
295
+ const [next, ...rest] = state.queued;
296
+ state.queued = rest;
297
+ notify();
298
+ await self.submit(next);
299
+ }
300
+ };
175
301
  const capture = async (fn) => {
176
302
  try {
177
303
  await fn((line) => push("line", line));
@@ -198,24 +324,72 @@ export async function createController(options) {
198
324
  else if (name === "work" && sub === "show" && id)
199
325
  await capture((write) => workShow({ workspaceRoot: options.workspaceRoot, id, write }));
200
326
  else if (name === "work" && sub === "resume" && id) {
201
- await stoppable(async (signal) => {
202
- try {
203
- await workResume({
204
- workspaceRoot: options.workspaceRoot,
205
- providers: options.providers,
206
- id,
207
- signal,
208
- write: (text) => push("line", text),
209
- ask: (q) => ask(id, q),
210
- });
327
+ try {
328
+ const work = await session.select(id);
329
+ push("notice", `${work.id}(${statusLabel(work.status)}、${work.objective})を候補にしました。次の依頼がこの Work に沿えば続けます。`);
330
+ }
331
+ catch (err) {
332
+ push("notice", message(err));
333
+ }
334
+ }
335
+ else if (name === "approvals") {
336
+ try {
337
+ const held = await session.approvals();
338
+ if (held.length === 0)
339
+ push("line", "承認待ちはありません。");
340
+ for (const a of held) {
341
+ push("line", `${a.approvalId} ${a.name} ${describeInput(a.input)} (${a.workId})`);
211
342
  }
212
- catch (err) {
213
- if (!signal.aborted)
214
- push("notice", message(err));
343
+ }
344
+ catch (err) {
345
+ push("notice", message(err));
346
+ }
347
+ }
348
+ else if ((name === "approve" || name === "reject") && sub) {
349
+ try {
350
+ const comment = args.slice(1).join(" ");
351
+ const { text } = await session.decide(sub, name, comment || undefined);
352
+ push("line", text);
353
+ }
354
+ catch (err) {
355
+ push("notice", message(err));
356
+ }
357
+ }
358
+ else if (name === "approve" || name === "reject") {
359
+ push("notice", `/${name} には承認の id が要ります。/approvals で確かめてください。`);
360
+ }
361
+ else if (name === "review" && sub && (args[1] === "approve" || args[1] === "reject")) {
362
+ const decision = args[1];
363
+ try {
364
+ // The rule already says which role has to decide; the person only says who they are.
365
+ const held = (await session.approvals()).find((a) => a.approvalId === sub);
366
+ if (!held)
367
+ throw new Error(`${sub} は承認待ちにありません。/approvals で確かめてください。`);
368
+ if (held.kind !== "review") {
369
+ throw new Error(`${sub} は人の承認待ちです。/approve か /reject で決めます。`);
215
370
  }
216
- if (signal.aborted)
217
- push("notice", stopped(id));
218
- });
371
+ const role = held.reviewer?.role ?? "reviewer";
372
+ const who = await askLine(`${role} の名前と資格(例: 田中 太郎 / 税理士)。会社の申告として記録します`);
373
+ const [reviewerName, qualification] = who.split("/").map((part) => part.trim());
374
+ const interpretation = await askLine(decision === "approve" ? "判断の本文(そのまま記録します)" : "認めない理由");
375
+ const { text } = await session.review({
376
+ approvalId: sub,
377
+ decision,
378
+ reviewer: {
379
+ name: reviewerName || who,
380
+ role,
381
+ ...(qualification && { qualification }),
382
+ },
383
+ interpretation,
384
+ });
385
+ push("line", text);
386
+ }
387
+ catch (err) {
388
+ push("notice", message(err));
389
+ }
390
+ }
391
+ else if (name === "review") {
392
+ push("notice", "/review <id> approve か /review <id> reject の形です。");
219
393
  }
220
394
  else if (name === "resume") {
221
395
  push("notice", "セッションの再開はまだありません。止まった Work を続けるなら /work resume <id> です。");
@@ -228,14 +402,23 @@ export async function createController(options) {
228
402
  closing ??= (async () => {
229
403
  aborter?.abort();
230
404
  settleQuestion();
405
+ settleApproval();
231
406
  await running;
232
- await session.close();
233
- state.closed = true;
234
- notify();
407
+ try {
408
+ await session.close();
409
+ }
410
+ catch (err) {
411
+ push("notice", `会話の記録を閉じられませんでした。${message(err)}`);
412
+ }
413
+ finally {
414
+ await client.close().catch(() => undefined);
415
+ state.closed = true;
416
+ notify();
417
+ }
235
418
  })();
236
419
  return closing;
237
420
  }
238
- return {
421
+ const self = {
239
422
  sessionId: session.id,
240
423
  state: () => state,
241
424
  subscribe(listener) {
@@ -246,6 +429,15 @@ export async function createController(options) {
246
429
  const text = line.trim();
247
430
  if (text === "" || closing)
248
431
  return;
432
+ if (state.approval) {
433
+ push("notice", "承認を先に決めてください。数字か ↑↓ と Enter で選びます。");
434
+ return;
435
+ }
436
+ if (state.reason !== undefined) {
437
+ push("user", text);
438
+ settleApproval("reject", text);
439
+ return;
440
+ }
249
441
  if (pending) {
250
442
  push("user", text);
251
443
  // Everything typed answers the question, except leaving: that takes the question back.
@@ -256,7 +448,10 @@ export async function createController(options) {
256
448
  return;
257
449
  }
258
450
  if (state.busy) {
259
- push("notice", "いま動いています。止めるなら Ctrl-C。");
451
+ // Typing while the agent works is not a mistake: the line waits its turn.
452
+ state.queued = [...state.queued, text];
453
+ push("notice", `順番待ち(${state.queued.length} 件): ${text}`);
454
+ notify();
260
455
  return;
261
456
  }
262
457
  if (text.startsWith("/")) {
@@ -265,11 +460,16 @@ export async function createController(options) {
265
460
  return;
266
461
  }
267
462
  push("user", text);
463
+ unreported = [];
268
464
  await stoppable(async (signal) => {
269
465
  try {
270
466
  const result = await session.turn(text, { signal });
271
- if (result.reply)
272
- push("assistant", result.reply);
467
+ // What the work recorded is the agent's own writing, so it stands in when the turn
468
+ // ends with nothing said. Without this the person is left with a work that finished
469
+ // and no answer, which is what a model that skips its summary leaves behind.
470
+ const reply = result.reply.trim() === "" ? unreported.join("\n\n") : result.reply;
471
+ if (reply)
472
+ push("assistant", reply);
273
473
  const note = explain(result);
274
474
  if (note)
275
475
  push("notice", note);
@@ -278,21 +478,50 @@ export async function createController(options) {
278
478
  push("notice", message(err));
279
479
  }
280
480
  });
481
+ await drainQueue();
281
482
  },
282
483
  interrupt() {
283
484
  if (!aborter)
284
485
  return false;
285
486
  aborter.abort();
286
487
  settleQuestion();
488
+ settleApproval();
287
489
  return true;
288
490
  },
491
+ moveApproval(delta) {
492
+ const approval = state.approval;
493
+ if (!approval)
494
+ return;
495
+ const count = approval.choices.length;
496
+ state.approval = { ...approval, at: (approval.at + delta + count) % count };
497
+ notify();
498
+ },
499
+ decideApproval(choice) {
500
+ const approval = state.approval;
501
+ if (!approval)
502
+ return;
503
+ const picked = choice ?? approval.choices[approval.at]?.key ?? "reject";
504
+ if (picked === "reject_with_reason") {
505
+ // The palette closes and the input box takes the reason; the loop still waits.
506
+ const shown = APPROVAL_CHOICES.find((c) => c.key === picked)?.label;
507
+ delete state.approval;
508
+ state.reason = "実行しない理由(社員エージェントに伝わります)";
509
+ if (shown)
510
+ push("line", `> ${shown}`);
511
+ push("question", state.reason);
512
+ notify();
513
+ return;
514
+ }
515
+ settleApproval(picked);
516
+ },
289
517
  close,
290
518
  };
519
+ return self;
291
520
  }
292
- /** Lines that close a work in the screen: the CLI's closing lines without the summary, which the clerk relays. */
293
- export async function workReport(runtime, workId) {
294
- const work = await runtime.works.get(workId);
295
- const events = await runtime.works.events(workId);
521
+ /** Lines that close a work in the screen: the CLI's closing lines without the summary, which the agent relays. */
522
+ export async function workReport(store, workId) {
523
+ const work = await store.get(workId);
524
+ const events = await store.events(workId);
296
525
  const lines = report(work, events);
297
526
  return work.status === "completed" ? ["完了。", ...lines.slice(1)] : lines;
298
527
  }
package/dist/tui/index.js CHANGED
@@ -40,6 +40,6 @@ export async function startTui(options) {
40
40
  finally {
41
41
  leave();
42
42
  }
43
- console.log(`会話を終えました。記録は openshain work show ${controller.sessionId} で読めます。`);
43
+ console.log(`会話を終えました。記録は openshain work show ${controller.sessionId} で参照します。`);
44
44
  return 0;
45
45
  }
@@ -1,12 +1,14 @@
1
- import { type Segment } from "./banner.ts";
2
1
  import type { Entry, EntryKind } from "./controller.ts";
2
+ import { type Span } from "./markdown.ts";
3
3
  export interface ScreenLine {
4
4
  kind: EntryKind | "blank";
5
+ /** The row as plain characters, marker included. */
5
6
  text: string;
6
- /** Colored pieces of a logo row; the other rows are one color. */
7
- segments?: Segment[];
7
+ /** The row as styled pieces: a logo row, or a reply the screen drew from its markdown. */
8
+ spans?: Span[];
8
9
  }
9
10
  /** Breaks text into lines no wider than `width` display columns, counting East Asian wide characters as two. */
10
11
  export declare function wrapText(text: string, width: number): string[];
12
+ export declare function rowsFor(entry: Entry, width: number): Span[][];
11
13
  /** The rows the screen shows for the entries, wrapped to the width, with markers and blank rows between blocks. */
12
14
  export declare function screenLines(entries: readonly Entry[], width: number): ScreenLine[];