@nanhara/hara 0.121.0 → 0.122.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 (49) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +40 -6
  3. package/dist/agent/failover.js +1 -1
  4. package/dist/agent/loop.js +158 -21
  5. package/dist/agent/reminders.js +22 -7
  6. package/dist/agent/repeat-guard.js +26 -7
  7. package/dist/agent/structured.js +231 -0
  8. package/dist/agent/touched.js +20 -6
  9. package/dist/config.js +62 -26
  10. package/dist/cron/deliver.js +37 -3
  11. package/dist/feedback.js +5 -9
  12. package/dist/fs-read.js +106 -12
  13. package/dist/fs-write.js +242 -16
  14. package/dist/gateway/dingtalk.js +4 -1
  15. package/dist/gateway/discord.js +53 -18
  16. package/dist/gateway/feishu.js +158 -57
  17. package/dist/gateway/flows-pending.js +720 -0
  18. package/dist/gateway/flows.js +391 -16
  19. package/dist/gateway/matrix.js +80 -15
  20. package/dist/gateway/mattermost.js +44 -32
  21. package/dist/gateway/media.js +659 -0
  22. package/dist/gateway/serve.js +657 -162
  23. package/dist/gateway/sessions.js +475 -78
  24. package/dist/gateway/signal.js +27 -22
  25. package/dist/gateway/slack.js +26 -17
  26. package/dist/gateway/telegram.js +32 -18
  27. package/dist/gateway/wecom.js +32 -24
  28. package/dist/gateway/weixin.js +127 -49
  29. package/dist/hooks.js +32 -20
  30. package/dist/index.js +640 -219
  31. package/dist/org/projects.js +347 -0
  32. package/dist/org/roles.js +38 -11
  33. package/dist/security/secrets.js +150 -0
  34. package/dist/serve/server.js +772 -317
  35. package/dist/serve/sessions.js +112 -28
  36. package/dist/session/store.js +337 -44
  37. package/dist/tools/all.js +1 -0
  38. package/dist/tools/builtin.js +61 -23
  39. package/dist/tools/codebase.js +3 -1
  40. package/dist/tools/computer.js +98 -92
  41. package/dist/tools/edit.js +11 -8
  42. package/dist/tools/patch.js +230 -31
  43. package/dist/tools/search.js +482 -72
  44. package/dist/tools/task.js +453 -0
  45. package/dist/tools/todo.js +67 -16
  46. package/dist/tools/web.js +364 -64
  47. package/dist/tui/run.js +26 -23
  48. package/dist/undo.js +83 -7
  49. package/package.json +1 -1
@@ -14,6 +14,25 @@ export class SessionHub {
14
14
  constructor(store = realStore) {
15
15
  this.store = store;
16
16
  }
17
+ /** Mutate an on-disk session under the same single-writer lock used by live sessions. The load happens
18
+ * only AFTER acquisition, so a writer that finished immediately before us cannot be overwritten by a
19
+ * stale pre-lock snapshot. */
20
+ mutateStored(id, mutate) {
21
+ const lock = this.store.acquire(id);
22
+ if (!lock.ok)
23
+ return false;
24
+ try {
25
+ const current = this.store.load(id);
26
+ if (!current)
27
+ return false;
28
+ mutate(current);
29
+ this.store.save(current.meta, current.history);
30
+ return true;
31
+ }
32
+ finally {
33
+ this.store.release(id);
34
+ }
35
+ }
17
36
  create(o) {
18
37
  const meta = {
19
38
  id: newSessionId(),
@@ -25,29 +44,66 @@ export class SessionHub {
25
44
  updatedAt: "",
26
45
  source: "interactive", // serve sessions are user-driven (desktop/IDE clients)
27
46
  };
28
- this.store.acquire(meta.id); // fresh id always ours; registers the single-writer claim
29
- const s = { meta, history: [], provider: o.provider, approval: o.approval, autoApprove: new Set(), stats: { input: 0, output: 0 }, projectContext: o.projectContext, busy: false, abort: null };
30
- this.sessions.set(meta.id, s);
31
- return s;
47
+ const lock = this.store.acquire(meta.id); // fresh UUID, but filesystem errors must still fail closed
48
+ if (!lock.ok)
49
+ throw new Error(`could not acquire session lock for ${meta.id}${lock.pid ? ` (held by pid ${lock.pid})` : ""}`);
50
+ const s = { meta, history: [], provider: o.provider, approval: o.approval, autoApprove: new Set(), stats: { input: 0, output: 0 }, projectContext: o.projectContext, busy: false, configuring: false, pendingProviderTurns: 0, abort: null };
51
+ try {
52
+ this.sessions.set(meta.id, s);
53
+ this.store.save(meta, []); // an empty newly-created thread must survive restart and appear in lists
54
+ return s;
55
+ }
56
+ catch (error) {
57
+ this.sessions.delete(meta.id);
58
+ this.store.release(meta.id);
59
+ throw error;
60
+ }
32
61
  }
33
62
  /** Resume a persisted session. Returns the live session, or a lock/missing failure. */
34
63
  resume(id, o) {
35
64
  const live = this.sessions.get(id);
65
+ if (live?.busy || live?.configuring)
66
+ return { busy: true };
36
67
  if (live)
37
68
  return { session: live }; // already attached to this server
38
- const prior = this.store.load(id);
39
- if (!prior)
40
- return { missing: true };
41
69
  const lock = this.store.acquire(id);
42
70
  if (!lock.ok)
43
71
  return { lockedBy: lock.pid ?? 0 };
44
- const s = { meta: prior.meta, history: [...prior.history], provider: o.provider, approval: o.approval, autoApprove: new Set(), stats: { input: 0, output: 0 }, projectContext: o.projectContext, busy: false, abort: null };
45
- this.sessions.set(id, s);
46
- return { session: s };
72
+ let keepLock = false;
73
+ try {
74
+ const prior = this.store.load(id); // lock-before-load: this is the authoritative latest snapshot
75
+ if (!prior)
76
+ return { missing: true };
77
+ // Credential/provider routing is live, while the model remains the session's explicit pin.
78
+ prior.meta.provider = o.provider.id;
79
+ const s = { meta: prior.meta, history: [...prior.history], provider: o.provider, approval: o.approval, autoApprove: new Set(), stats: { input: 0, output: 0 }, projectContext: o.projectContext, busy: false, configuring: false, pendingProviderTurns: 0, abort: null, effort: prior.meta.effort };
80
+ this.sessions.set(id, s);
81
+ keepLock = true; // live session owns it until delete/releaseAll
82
+ return { session: s };
83
+ }
84
+ finally {
85
+ if (!keepLock)
86
+ this.store.release(id);
87
+ }
47
88
  }
48
89
  get(id) {
49
90
  return this.sessions.get(id);
50
91
  }
92
+ /** Drop an attached but idle session and release its lock without deleting persistence. This is used
93
+ * when resume attached successfully but live provider validation failed before the client got a handle. */
94
+ detach(id) {
95
+ const live = this.sessions.get(id);
96
+ if (!live || live.busy || live.configuring)
97
+ return false;
98
+ this.sessions.delete(id);
99
+ this.store.release(id);
100
+ return true;
101
+ }
102
+ /** Read model/cwd routing metadata without attaching the session. The authoritative resume still reloads
103
+ * after acquiring its lock; callers use this only to build the likely provider before that handoff. */
104
+ peekMeta(id) {
105
+ return this.sessions.get(id)?.meta ?? this.store.load(id)?.meta;
106
+ }
51
107
  list(cwd) {
52
108
  return this.store.list(cwd);
53
109
  }
@@ -64,36 +120,36 @@ export class SessionHub {
64
120
  rename(id, title) {
65
121
  const live = this.sessions.get(id);
66
122
  if (live) {
123
+ if (live.busy || live.configuring)
124
+ return false;
67
125
  live.meta.title = title;
68
126
  this.store.save(live.meta, live.history);
69
127
  return true;
70
128
  }
71
- const prior = this.store.load(id);
72
- if (!prior)
73
- return false;
74
- prior.meta.title = title;
75
- this.store.save(prior.meta, prior.history);
76
- return true;
129
+ return this.mutateStored(id, (current) => {
130
+ current.meta.title = title;
131
+ });
77
132
  }
78
133
  /** Archive/unarchive (hidden from lists, kept on disk). Returns false when unknown. */
79
134
  setArchived(id, on) {
80
135
  const live = this.sessions.get(id);
81
136
  if (live) {
137
+ if (live.busy || live.configuring)
138
+ return false;
82
139
  live.meta.archived = on;
83
140
  this.store.save(live.meta, live.history);
84
141
  return true;
85
142
  }
86
- const prior = this.store.load(id);
87
- if (!prior)
88
- return false;
89
- prior.meta.archived = on;
90
- this.store.save(prior.meta, prior.history);
91
- return true;
143
+ return this.mutateStored(id, (current) => {
144
+ current.meta.archived = on;
145
+ });
92
146
  }
93
147
  /** Fork: duplicate a session's history into a NEW session (codex thread/fork) — the non-destructive
94
148
  * sibling of rewind. Source may be live or on-disk; the fork is always a fresh live session. */
95
149
  fork(id, o) {
96
150
  const live = this.sessions.get(id);
151
+ if (live?.busy || live?.configuring)
152
+ return { busy: true };
97
153
  const src = live ?? this.store.load(id);
98
154
  if (!src)
99
155
  return { missing: true };
@@ -106,8 +162,13 @@ export class SessionHub {
106
162
  createdAt: new Date().toISOString(),
107
163
  updatedAt: "",
108
164
  source: "interactive",
165
+ ...(src.meta.workingSet ? { workingSet: [...src.meta.workingSet] } : {}),
166
+ ...(src.meta.todos ? { todos: src.meta.todos.map((todo) => ({ ...todo, ...(todo.blockedBy ? { blockedBy: [...todo.blockedBy] } : {}) })) } : {}),
167
+ ...(src.meta.effort ? { effort: src.meta.effort } : {}),
109
168
  };
110
- this.store.acquire(meta.id);
169
+ const lock = this.store.acquire(meta.id);
170
+ if (!lock.ok)
171
+ throw new Error(`could not acquire fork lock for ${meta.id}${lock.pid ? ` (held by pid ${lock.pid})` : ""}`);
111
172
  const s = {
112
173
  meta,
113
174
  history: [...src.history],
@@ -117,20 +178,30 @@ export class SessionHub {
117
178
  stats: { input: 0, output: 0 },
118
179
  projectContext: o.projectContext,
119
180
  busy: false,
181
+ configuring: false,
182
+ pendingProviderTurns: 0,
120
183
  abort: null,
184
+ effort: src.meta.effort,
121
185
  };
122
- this.sessions.set(meta.id, s);
123
- this.store.save(meta, s.history); // persist immediately — a fork should survive a crash unsent
124
- return { session: s };
186
+ try {
187
+ this.sessions.set(meta.id, s);
188
+ this.store.save(meta, s.history); // persist immediately — a fork should survive a crash unsent
189
+ return { session: s };
190
+ }
191
+ catch (error) {
192
+ this.sessions.delete(meta.id);
193
+ this.store.release(meta.id);
194
+ throw error;
195
+ }
125
196
  }
126
197
  /** Permanently delete (live or on-disk). Refuses a busy live session. Returns:
127
198
  * "gone" on success, "busy" when a turn is running, "missing" when unknown/held elsewhere. */
128
199
  delete(id) {
129
200
  const live = this.sessions.get(id);
130
- if (live?.busy)
201
+ if (live?.busy || live?.configuring)
131
202
  return "busy";
132
203
  const ok = this.store.delete(id);
133
- if (!ok && !live)
204
+ if (!ok)
134
205
  return "missing";
135
206
  if (live)
136
207
  this.sessions.delete(id);
@@ -143,4 +214,17 @@ export class SessionHub {
143
214
  this.store.release(id);
144
215
  this.sessions.clear();
145
216
  }
217
+ /** Snapshot live sessions for graceful shutdown/health handling. */
218
+ active() {
219
+ return [...this.sessions.values()];
220
+ }
221
+ /** Release only idle sessions; logical work and abandoned-but-physical provider turns retain their locks. */
222
+ releaseIdle() {
223
+ for (const [id, session] of this.sessions) {
224
+ if (session.busy || session.configuring || session.pendingProviderTurns > 0)
225
+ continue;
226
+ this.store.release(id);
227
+ this.sessions.delete(id);
228
+ }
229
+ }
146
230
  }